diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml
index 53418f763d4..852423f1231 100644
--- a/.github/workflows/publish-sim-cli.yml
+++ b/.github/workflows/publish-sim-cli.yml
@@ -15,6 +15,13 @@ concurrency:
jobs:
publish-npm:
+ # Job-level, not on the build step: `bun publish` runs `prepublishOnly`,
+ # which rebuilds `dist` a second time, and that second build is the one
+ # that ships. A build without the token reports nothing. See
+ # docs/cli/usage-data.
+ env:
+ SIM_CLI_TELEMETRY_KEY: ${{ vars.SIM_CLI_TELEMETRY_KEY }}
+ SIM_CLI_TELEMETRY_HOST: ${{ vars.SIM_CLI_TELEMETRY_HOST }}
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
@@ -63,11 +70,6 @@ jobs:
- name: Build package
working-directory: packages/sim-cli
- env:
- # Public PostHog project token for anonymous CLI usage reporting; a
- # build without it reports nothing. See docs/cli/usage-data.
- SIM_CLI_TELEMETRY_KEY: ${{ vars.SIM_CLI_TELEMETRY_KEY }}
- SIM_CLI_TELEMETRY_HOST: ${{ vars.SIM_CLI_TELEMETRY_HOST }}
run: bun run build
- name: Resolve release channel
diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx
index 52da0ef7f74..31110c79c1c 100644
--- a/apps/docs/content/docs/knowledgebase/connectors.mdx
+++ b/apps/docs/content/docs/knowledgebase/connectors.mdx
@@ -8,7 +8,7 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
-For workspace Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases.
+For organization Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases.
Connectors continuously sync documents from external services into your knowledge base, so you never have to upload files manually. New content is added, changed content is re-processed, and deleted content is removed — all automatically.
diff --git a/apps/docs/content/docs/platform/enterprise/index.mdx b/apps/docs/content/docs/platform/enterprise/index.mdx
index a781bcc40d9..be354a1a18e 100644
--- a/apps/docs/content/docs/platform/enterprise/index.mdx
+++ b/apps/docs/content/docs/platform/enterprise/index.mdx
@@ -11,6 +11,7 @@ Sim Enterprise adds organization controls for access, provisioning, operations,
| [Single sign-on](/platform/enterprise/sso) | Connect a SAML or OIDC identity provider |
| [Verified domains](/platform/enterprise/verified-domains) | Prove domain ownership for SSO and provisioning |
| [Directory provisioning](/platform/enterprise/scim) | Sync users and group-based access from your directory |
+| [Security](/platform/enterprise/security) | Manage session policies and view configured outbound IP addresses |
| [Session policies](/platform/enterprise/session-policies) | Set session lifetimes and revoke member sessions |
| [Audit logs](/platform/enterprise/audit-logs) | Investigate configuration and security events |
| [Usage tracking](/platform/enterprise/usage-tracking) | Review usage by member, workspace, model, and source |
diff --git a/apps/docs/content/docs/platform/enterprise/meta.json b/apps/docs/content/docs/platform/enterprise/meta.json
index 123eec90b29..c87d6bad42d 100644
--- a/apps/docs/content/docs/platform/enterprise/meta.json
+++ b/apps/docs/content/docs/platform/enterprise/meta.json
@@ -5,6 +5,7 @@
"sso",
"scim",
"verified-domains",
+ "security",
"session-policies",
"access-control",
"custom-blocks",
diff --git a/apps/docs/content/docs/platform/enterprise/security.mdx b/apps/docs/content/docs/platform/enterprise/security.mdx
new file mode 100644
index 00000000000..0a67efba9cd
--- /dev/null
+++ b/apps/docs/content/docs/platform/enterprise/security.mdx
@@ -0,0 +1,34 @@
+---
+title: Security
+description: Manage organization session policies and view configured outbound IP addresses
+---
+
+Organization owners and admins open **Settings → Security** to manage session policies and view outbound IP addresses.
+
+## Session policies
+
+Set **Max session lifetime** and **Idle timeout** in hours, then select **Save**. Leave either field empty to use its default behavior. **Discard** restores your saved values.
+
+**Sign out all members** opens a confirmation before revoking the organization's browser sessions, except your current session. It does not revoke API keys.
+
+See [Session policies](/platform/enterprise/session-policies) for limits, defaults, and how changes affect existing sessions.
+
+## Outbound IP addresses
+
+Copy your organization's configured addresses in `/32` format and allowlist **every listed address** on the destination firewall. These addresses apply to supported HTTPS connections from Sim and its background workers.
+
+Contact Sim support or your deployment administrator to configure dedicated IPs and confirm connection coverage.
+
+### Connection coverage
+
+Dedicated routing supports public IPv4 destinations over HTTPS on port 443. It applies to supported Search connector API requests, HTTP request blocks, MCP and A2A connections, and HTTP requests made through Sim's local Function runtime. Background jobs use the same organization assignment.
+
+Native database connections, AWS SDK integrations, remote sandbox traffic, and browser requests keep their existing network paths. Other provider SDKs and OAuth authorization or token refresh calls require separate coverage confirmation. Dedicated IPs do not change access permissions in connected services.
+
+Google Drive, Fireflies, Google Workspace user and group discovery, and Atlassian OAuth site discovery currently use their existing network paths.
+
+If dedicated routing is unavailable, affected requests fail instead of using shared IPs. Organizations without dedicated routing keep their existing behavior.
+
+## Availability
+
+On Sim Cloud, Security settings require an Enterprise organization and an owner or admin role. On self-hosted deployments, the outbound IP section is available to organization administrators; session controls appear only when session policies are enabled. See [self-hosted enterprise configuration](/platform/enterprise/self-hosted).
diff --git a/apps/docs/content/docs/platform/enterprise/session-policies.mdx b/apps/docs/content/docs/platform/enterprise/session-policies.mdx
index 73ddd8fd2a4..b4b0d4267dd 100644
--- a/apps/docs/content/docs/platform/enterprise/session-policies.mdx
+++ b/apps/docs/content/docs/platform/enterprise/session-policies.mdx
@@ -11,7 +11,7 @@ Session Policies let organization owners and admins on Enterprise plans control
## Setup
-Go to **Settings → Organization → Session policies** in your organization settings.
+Go to **Settings → Security → Session policies** in your organization settings.
Both limits are optional. Leave a field empty to keep the default behavior: sessions last 30 days and extend automatically while a member stays active.
diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx
index 1f7887a2de1..5d63dbe45b7 100644
--- a/apps/docs/content/docs/search/confluence.mdx
+++ b/apps/docs/content/docs/search/confluence.mdx
@@ -56,8 +56,6 @@ Each teammate completes this identity connection. An existing authorized account
-For workspace Search, start from **Search → Add source**. Available methods depend on the enabled features.
-
## Connect member accounts
After an admin configures Confluence, open **Integrations** and select **Connect** beside Confluence. Authorize your account; you do not enter the site's domain or choose its spaces again.
@@ -132,7 +130,7 @@ Central sources combine space permissions, page and ancestor restrictions, and g
Open **Settings → Sources → Confluence**, then a source's **Documents**, **Settings**, or **Sync history**. Invite teammates through **Settings → Members → Invite** or SSO, then have them connect through **Integrations**. **People → Request connections** only requests a provider connection; it does not invite people to the organization.
-Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. **Full resync**, available for service-account connections, fetches unchanged content again and asks for confirmation. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately.
+Syncing runs automatically. Admins can use **Sync now** for an immediate update, **Pause syncing** to stop scheduled syncs, or **Resume syncing** to restart them. Successful manual syncs have a one-minute cooldown; failed syncs can be retried immediately.
## Troubleshooting
diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx
index 353fc520956..b82cdf817a2 100644
--- a/apps/docs/content/docs/search/connect-your-account.mdx
+++ b/apps/docs/content/docs/search/connect-your-account.mdx
@@ -78,7 +78,7 @@ On the main **Integrations** page, select **Reconnect** beside the integration i
Admins manage setup from **Settings → Sources**. Open an integration, then its connection to see **Documents**, **Settings**, and **Sync history**. **People** shows account contributors across integrations and supports filtering by integration. This does not grant the admin access to every document.
-Syncing runs automatically. Admins can use **Sync now** when they need an update; another manual run is available 60 seconds after a successful sync finishes. Failed or partial runs can be retried immediately. The connection header also offers **Pause syncing** or **Resume syncing**, and **Remove connection**. Where supported, **Full resync** fetches and reindexes all content and asks for confirmation first.
+Syncing runs automatically. Admins can use **Sync now** when they need an update; another manual run is available 60 seconds after a successful sync finishes. Failed or partial runs can be retried immediately. The connection header also offers **Pause syncing** or **Resume syncing**, and **Remove connection**.
Removing a Search connection also removes its indexed documents from Sim. The originals remain in the connected app.
diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx
index 516e03cf2a6..18b737e4efc 100644
--- a/apps/docs/content/docs/search/github.mdx
+++ b/apps/docs/content/docs/search/github.mdx
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
GitHub Search indexes repository text files and supported PDF and Office documents on `github.com`. An organization admin can install the GitHub App once and use it to index selected repositories. Each person connects their own GitHub account once to search the repositories they can access. Installing the App does not connect teammates or give them the installer's permissions.
-Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. For workspace Search, use **Search → Add source** with member accounts or a dedicated user account.
+Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search.
## Before you start
diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx
index 7dbc4f15fa5..b82a0664b76 100644
--- a/apps/docs/content/docs/search/gitlab.mdx
+++ b/apps/docs/content/docs/search/gitlab.mdx
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
Connect one GitLab project per source. Choose an **Administrator token** to refresh permissions from GitLab, or a **Non-admin token** with two CSV files to manage access in Sim. Teammates do not connect individual GitLab accounts.
-Organization admins set up sources in **Settings → Sources**. For workspace Search, use **Search → Add source**. The same token paths are available when adding a GitLab connector to a regular knowledge base.
+Organization admins set up sources in **Settings → Sources**. The same token paths are available when adding a GitLab connector to a regular knowledge base.
## Choose a token path
@@ -146,7 +146,7 @@ Open a project to use these administrator actions:
| **Settings** | Change the token, project, filters, or CSV permissions. |
| **Remove connection** | Confirm removal of the connection and its indexed documents. Documents cannot be retained without the connection that maintains their permissions. |
-GitLab does not expose a separate **Full resync** action. Each sync checks the selected content. CSV grants change only when you replace the files.
+Each sync checks the selected content. CSV grants change only when you replace the files.
## Troubleshooting
diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx
index 8945d725c4b..c030c526062 100644
--- a/apps/docs/content/docs/search/gmail.mdx
+++ b/apps/docs/content/docs/search/gmail.mdx
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
Search email threads from your Gmail account. Members can connect their own accounts, or an administrator can index Google Workspace mailboxes with a service account. In either case, each mailbox stays private to its owner.
-Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead.
+Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar.
## Choose your setup
diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx
index 246252a3a6a..2f302719ebc 100644
--- a/apps/docs/content/docs/search/google-calendar.mdx
+++ b/apps/docs/content/docs/search/google-calendar.mdx
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
Search meetings and event details available to your Google account. Members can connect personal accounts, or a Google Workspace administrator can configure a central service-account crawl. Both paths keep each person's own view of events separate.
-Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead.
+Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar.
## Choose your setup
diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx
index c1e01e57347..0565a5289f7 100644
--- a/apps/docs/content/docs/search/google-drive.mdx
+++ b/apps/docs/content/docs/search/google-drive.mdx
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
Search Google Docs, Sheets, Slides, and supported files in Drive. Members can connect their own accounts after an admin allows Google Drive, or an admin can configure a central service-account crawl.
-Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead.
+Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar.
## Choose your setup
diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx
index 68b53e20f2a..f2e080397ab 100644
--- a/apps/docs/content/docs/search/index.mdx
+++ b/apps/docs/content/docs/search/index.mdx
@@ -104,7 +104,7 @@ Edit **Settings** to change an existing connection's filters. Adding another con
**Sync using** shows the method selected when the source was created. Add a new connection to change that method. To replace a supported indexing credential, select its replacement and use **Change service account** or **Change account**, as shown.
-The connection header offers **Sync now**, **Pause syncing** or **Resume syncing**, **Remove connection**, and, where supported, **Full resync**. Full resync fetches all content again and requires confirmation. Manual runs have a 60-second cooldown after a successful sync finishes; failed or partial runs can be retried immediately. **Pause syncing** becomes available when the current sync finishes.
+The connection header offers **Sync now**, **Pause syncing** or **Resume syncing**, and **Remove connection**. Manual runs have a 60-second cooldown after a successful sync finishes; failed or partial runs can be retried immediately. **Pause syncing** becomes available when the current sync finishes.
To deactivate an entire integration, open it from **Settings → Sources**, select **Deactivate**, and confirm. Its content becomes unavailable in Search, Assistant, and MCP; saved connections remain. Select **Activate** on that integration to enable it again.
@@ -120,10 +120,6 @@ To deactivate an entire integration, open it from **Settings → Sources**, sele
To use these sources from Claude, Codex, Claude Code, Cursor, or another compatible app, open **Settings → Search MCP**. Each person signs in with their own Sim account. The server provides `search`, `read_document`, and `chat`; `chat` starts a new private Sim conversation. See [Search MCP](/search/mcp) for app setup, permissions, and limits.
-## Existing workspace Search
-
-Workspace Search remains separate. Workspace admins add sources through **Search → Add source**; the member-account action is **Create & Invite**. Teammates need workspace access and connect from its source list. Organization Search does not automatically include workspace sources or grant access to workspace content.
-
## Check that it works
1. Let the first sync finish, then search for a distinctive phrase in a document you can open in the source.
diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx
index bb41c9f9581..9faacf30110 100644
--- a/apps/docs/content/docs/search/jira.mdx
+++ b/apps/docs/content/docs/search/jira.mdx
@@ -71,8 +71,6 @@ Each teammate follows this step. Sim reuses an existing authorized account where
-For workspace Search, start from **Search → Add source**; its source-creation button is **Create & Invite**. Search manages the sync schedule and hides item limits.
-
## Connect before a source exists
After an admin approves Jira, a teammate can select **Connect** on the Jira row in **Integrations**. To add another site or project selection later, open the Jira row's actions menu (**…**) and select **Add projects**:
diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx
index bd158b43996..26544966a73 100644
--- a/apps/docs/content/docs/search/slack.mdx
+++ b/apps/docs/content/docs/search/slack.mdx
@@ -134,8 +134,6 @@ Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table
Existing workflow account pools retain their configured permissions; preserve those scopes when updating the shared app.
-For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests read-only channel, DM, and identity scopes. Its bot installation is separate from the organization setup described here.
-
See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/).
## Troubleshooting
diff --git a/apps/realtime/src/database/operations.test.ts b/apps/realtime/src/database/operations.test.ts
new file mode 100644
index 00000000000..fb81bbd304d
--- /dev/null
+++ b/apps/realtime/src/database/operations.test.ts
@@ -0,0 +1,171 @@
+/** @vitest-environment node */
+import { OPERATION_TARGETS, SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockTransaction, mockSelectWhere, mockSet } = vi.hoisted(() => ({
+ mockTransaction: vi.fn(),
+ mockSelectWhere: vi.fn(),
+ mockSet: vi.fn(),
+}))
+
+vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() }))
+vi.mock('@sim/db', () => ({
+ instrumentPoolClient: vi.fn(),
+ resolveDbUrl: vi.fn(() => 'postgres://localhost/test'),
+ workflow: { id: 'workflow.id' },
+ workflowBlocks: { id: 'block.id', workflowId: 'block.workflowId' },
+ workflowEdges: {},
+ workflowSubflows: {},
+}))
+vi.mock('@sim/db/timestamps', () => ({ withUtcTimestamps: (options: unknown) => options }))
+vi.mock('@sim/logger', () => ({
+ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
+}))
+vi.mock('@sim/platform-authz/workflow', () => ({
+ getActiveWorkflowContext: vi.fn().mockResolvedValue({ id: 'workflow-1' }),
+}))
+vi.mock('@sim/workflow-persistence/load', () => ({
+ loadWorkflowFromNormalizedTablesRaw: vi.fn(),
+}))
+vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubBlockValues: vi.fn() }))
+vi.mock('drizzle-orm', () => ({
+ and: vi.fn(),
+ eq: vi.fn(),
+ inArray: vi.fn(),
+ isNull: vi.fn(),
+ or: vi.fn(),
+ sql: vi.fn(),
+}))
+vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({ transaction: mockTransaction }) }))
+vi.mock('postgres', () => ({ default: vi.fn() }))
+vi.mock('@/env', () => ({
+ env: { DATABASE_URL: 'postgres://localhost/test' },
+}))
+
+import { persistWorkflowOperation } from '@/database/operations'
+
+const transaction = {
+ select: () => ({ from: () => ({ where: mockSelectWhere }) }),
+ update: () => ({ set: mockSet }),
+ delete: vi.fn(),
+ insert: vi.fn(),
+}
+
+describe('search replacement persistence', () => {
+ const expected = [
+ {
+ type: 'function',
+ params: { language: 'javascript', code: 'return 1' },
+ usageControl: 'none',
+ },
+ ]
+ const replacement = [{ ...expected[0], params: { ...expected[0].params, code: 'return 2' } }]
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTransaction.mockImplementation(
+ async (callback: (tx: typeof transaction) => Promise) => callback(transaction)
+ )
+ mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
+ })
+
+ function replaceTools(stored: unknown, expectedValue: unknown = expected) {
+ mockSelectWhere.mockResolvedValue([
+ {
+ id: 'agent-1',
+ type: 'agent',
+ locked: false,
+ data: {},
+ subBlocks: { tools: { id: 'tools', type: 'tool-input', value: stored } },
+ },
+ ])
+ return persistWorkflowOperation('workflow-1', {
+ operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE,
+ target: OPERATION_TARGETS.SUBBLOCK,
+ timestamp: Date.now(),
+ payload: {
+ updates: [{ blockId: 'agent-1', subblockId: 'tools', value: replacement, expectedValue }],
+ },
+ })
+ }
+
+ it('accepts equivalent nested tool objects after JSONB changes their key order', async () => {
+ const stored = [
+ {
+ usageControl: 'none',
+ params: { code: 'return 1', language: 'javascript' },
+ type: 'function',
+ },
+ ]
+
+ await expect(replaceTools(stored)).resolves.toBeUndefined()
+ expect(mockSet).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ subBlocks: { tools: { id: 'tools', type: 'tool-input', value: replacement } },
+ })
+ )
+ })
+
+ it('still rejects a tool parameter changed by another editor', async () => {
+ await expect(
+ replaceTools([{ ...expected[0], params: { ...expected[0].params, code: 'return 3' } }])
+ ).rejects.toThrow('changed since replacement was planned')
+ expect(mockSet).toHaveBeenCalledTimes(1)
+ })
+
+ it('still rejects reordered tool arrays', async () => {
+ const another = { ...expected[0], params: { ...expected[0].params, code: 'return 3' } }
+ await expect(replaceTools([another, expected[0]], [expected[0], another])).rejects.toThrow(
+ 'changed since replacement was planned'
+ )
+ expect(mockSet).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('subblock update with canonical modes persistence', () => {
+ const tools = [{ type: 'jira', params: { manualProjectId: '{{PROJECT}}' } }]
+ const canonicalModes = { '0:projectId': 'advanced' as const, model: 'basic' as const }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTransaction.mockImplementation(
+ async (callback: (tx: typeof transaction) => Promise) => callback(transaction)
+ )
+ mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
+ })
+
+ function updateTools(block: Record) {
+ mockSelectWhere.mockResolvedValue([
+ {
+ id: 'agent-1',
+ locked: false,
+ data: { width: 350, canonicalModes: { '1:projectId': 'advanced' } },
+ subBlocks: { tools: { id: 'tools', type: 'tool-input', value: [] } },
+ ...block,
+ },
+ ])
+ return persistWorkflowOperation('workflow-1', {
+ operation: SUBBLOCK_OPERATIONS.UPDATE_WITH_CANONICAL_MODES,
+ target: OPERATION_TARGETS.SUBBLOCK,
+ timestamp: Date.now(),
+ payload: { blockId: 'agent-1', subblockId: 'tools', value: tools, canonicalModes },
+ })
+ }
+
+ it('writes the subblock value and replaces canonical modes in one block update', async () => {
+ await expect(updateTools({})).resolves.toBeUndefined()
+
+ expect(mockSet).toHaveBeenCalledTimes(2)
+ expect(mockSet).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ subBlocks: { tools: { id: 'tools', type: 'tool-input', value: tools } },
+ data: { width: 350, canonicalModes },
+ })
+ )
+ })
+
+ it('rejects a locked block without writing either field', async () => {
+ await expect(updateTools({ locked: true })).rejects.toThrow('is locked')
+ expect(mockSet).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts
index f28543a8004..cd4316448bf 100644
--- a/apps/realtime/src/database/operations.ts
+++ b/apps/realtime/src/database/operations.ts
@@ -1,3 +1,4 @@
+import { isDeepStrictEqual } from 'node:util'
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import * as schema from '@sim/db'
import {
@@ -25,6 +26,7 @@ import {
import { randomFloat } from '@sim/utils/random'
import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load'
import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks'
+import type { DbOrTx } from '@sim/workflow-persistence/types'
import {
filterAcyclicEdges,
filterUniqueWorkflowEdges,
@@ -1988,8 +1990,36 @@ async function handleSubflowOperationTx(
}
}
-function valuesEqual(left: unknown, right: unknown): boolean {
- return JSON.stringify(left) === JSON.stringify(right)
+/** Every block in the workflow by id, for the locked-container check subblock writes need. */
+async function loadSubblockUpdateBlocks(tx: DbOrTx, workflowId: string) {
+ const allBlocks = await tx
+ .select({
+ id: workflowBlocks.id,
+ subBlocks: workflowBlocks.subBlocks,
+ locked: workflowBlocks.locked,
+ data: workflowBlocks.data,
+ })
+ .from(workflowBlocks)
+ .where(eq(workflowBlocks.workflowId, workflowId))
+ return Object.fromEntries(allBlocks.map((block) => [block.id, block]))
+}
+
+/**
+ * The block a subblock write targets, rejecting one that is missing, locked, or in a locked
+ * container.
+ */
+function getWritableSubblockUpdateBlock(
+ blocksById: Awaited>,
+ blockId: string
+) {
+ const block = blocksById[blockId]
+ if (!block) {
+ throw new Error(`Block ${blockId} not found`)
+ }
+ if (isWorkflowBlockProtected(blockId, blocksById)) {
+ throw new Error(`Block ${blockId} is locked or inside a locked container`)
+ }
+ return block
}
// Subblock operations - targeted value updates without replacing workflow state
@@ -2006,20 +2036,7 @@ async function handleSubblockOperationTx(
return
}
- const allBlocks = await tx
- .select({
- id: workflowBlocks.id,
- subBlocks: workflowBlocks.subBlocks,
- locked: workflowBlocks.locked,
- data: workflowBlocks.data,
- })
- .from(workflowBlocks)
- .where(eq(workflowBlocks.workflowId, workflowId))
-
- type SubblockUpdateBlockRecord = (typeof allBlocks)[number]
- const blocksById: Record = Object.fromEntries(
- allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block])
- )
+ const blocksById = await loadSubblockUpdateBlocks(tx, workflowId)
for (const update of updates) {
const { blockId, subblockId, value, expectedValue } = update
@@ -2027,19 +2044,13 @@ async function handleSubblockOperationTx(
throw new Error('Missing required fields for subblock batch update')
}
- const block = blocksById[blockId]
- if (!block) {
- throw new Error(`Block ${blockId} not found`)
- }
-
- if (isWorkflowBlockProtected(blockId, blocksById)) {
- throw new Error(`Block ${blockId} is locked or inside a locked container`)
- }
+ const block = getWritableSubblockUpdateBlock(blocksById, blockId)
const subBlocks = { ...((block.subBlocks as Record) || {}) }
const currentSubBlock = subBlocks[subblockId]
const currentValue = currentSubBlock?.value
- if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) {
+ /** JSONB can reorder object keys; changed values and array order must still conflict. */
+ if (expectedValue !== undefined && !isDeepStrictEqual(currentValue, expectedValue)) {
throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`)
}
@@ -2062,6 +2073,36 @@ async function handleSubblockOperationTx(
break
}
+ case SUBBLOCK_OPERATIONS.UPDATE_WITH_CANONICAL_MODES: {
+ const { blockId, subblockId, value, canonicalModes } = payload
+ if (!blockId || !subblockId || !canonicalModes) {
+ throw new Error('Missing required fields for subblock update with canonical modes')
+ }
+
+ const blocksById = await loadSubblockUpdateBlocks(tx, workflowId)
+ const block = getWritableSubblockUpdateBlock(blocksById, blockId)
+
+ const subBlocks = {
+ ...((block.subBlocks as Record> | null) || {}),
+ }
+ const currentSubBlock = subBlocks[subblockId]
+ subBlocks[subblockId] = currentSubBlock
+ ? { ...currentSubBlock, value }
+ : { id: subblockId, type: 'unknown', value }
+
+ await tx
+ .update(workflowBlocks)
+ .set({
+ subBlocks,
+ data: { ...((block.data as Record) || {}), canonicalModes },
+ updatedAt: new Date(),
+ })
+ .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
+
+ logger.debug(`Updated subblock ${blockId}.${subblockId} with canonical modes`)
+ break
+ }
+
default:
logger.warn(`Unknown subblock operation: ${operation}`)
throw new Error(`Unsupported subblock operation: ${operation}`)
diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts
index 4edc05d795a..97f32b1d2d2 100644
--- a/apps/realtime/src/middleware/permissions.test.ts
+++ b/apps/realtime/src/middleware/permissions.test.ts
@@ -114,6 +114,11 @@ describe('checkRolePermission', () => {
const result = checkRolePermission('write', 'subblock-batch-update')
expectPermissionAllowed(result)
})
+
+ it('should allow subblock-update-with-canonical-modes operation', () => {
+ const result = checkRolePermission('write', 'subblock-update-with-canonical-modes')
+ expectPermissionAllowed(result)
+ })
})
describe('read role', () => {
@@ -155,6 +160,11 @@ describe('checkRolePermission', () => {
expectPermissionDenied(result, 'read')
})
+ it('should deny subblock-update-with-canonical-modes operation for read role', () => {
+ const result = checkRolePermission('read', 'subblock-update-with-canonical-modes')
+ expectPermissionDenied(result, 'read')
+ })
+
it('should deny toggle-enabled operation for read role', () => {
const result = checkRolePermission('read', 'toggle-enabled')
expectPermissionDenied(result, 'read')
diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts
index e17678461a6..a007de6c8b2 100644
--- a/apps/realtime/src/middleware/permissions.ts
+++ b/apps/realtime/src/middleware/permissions.ts
@@ -52,6 +52,7 @@ const WRITE_OPERATIONS: string[] = [
// Subblock operations
SUBBLOCK_OPERATIONS.UPDATE,
SUBBLOCK_OPERATIONS.BATCH_UPDATE,
+ SUBBLOCK_OPERATIONS.UPDATE_WITH_CANONICAL_MODES,
// Variable operations
VARIABLE_OPERATIONS.UPDATE,
// Workflow operations
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx
index b659eae8b45..068a27599da 100644
--- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx
+++ b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item.tsx
@@ -2,7 +2,10 @@ import { Table } from '@sim/emcn/icons'
import { SlackIcon } from '@/components/icons'
import { ActivityStatus } from '@/components/ui/activity-status'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
-import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
+import type {
+ ToolActivityPresentation,
+ ToolCallItemProps,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
/** Demo fixtures have known brands, so the landing page never loads the block registry. */
@@ -11,6 +14,7 @@ export function HeroToolCallItem({
renderStatus,
toolName,
displayTitle,
+ activityDescription,
status,
}: ToolCallItemProps) {
const Icon =
@@ -19,12 +23,16 @@ export function HeroToolCallItem({
: toolCallId === 'hero-read-table'
? Table
: getToolIcon(toolName)
- const activity = (
- }
- />
- )
- return renderStatus ? renderStatus(activity) : activity
+ const activity: ToolActivityPresentation = {
+ label: getToolStatusDisplayTitle(displayTitle, status, toolName, activityDescription),
+ activeLabel: getToolStatusDisplayTitle(
+ displayTitle,
+ status === 'success' ? 'executing' : status,
+ toolName,
+ activityDescription
+ ),
+ isActive: status === 'executing',
+ icon: ,
+ }
+ return renderStatus ? renderStatus(activity) :
}
diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts
index b4c4ef1704f..db55de96da8 100644
--- a/apps/sim/app/api/auth/sso/register/route.test.ts
+++ b/apps/sim/app/api/auth/sso/register/route.test.ts
@@ -88,6 +88,7 @@ const OIDC_BODY = {
providerId: 'acme-oidc',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
+ orgId: 'org1',
clientId: 'client-id',
clientSecret: 'client-secret',
authorizationEndpoint: 'https://idp.acme.com/authorize',
@@ -137,21 +138,21 @@ describe('POST /api/auth/sso/register', () => {
it('rejects callers without an Enterprise plan', async () => {
mockHasSSOAccess.mockResolvedValue(false)
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects callers who are not an admin/owner of the target org', async () => {
queueMembers([{ organizationId: 'org1', role: 'member' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('rejects an invalid domain', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' }))
+ const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain' }))
expect(res.status).toBe(400)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
@@ -160,7 +161,7 @@ describe('POST /api/auth/sso/register', () => {
resetDbChainMock()
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, []) // no verified sso_domain row
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
const json = await res.json()
expect(res.status).toBe(403)
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
@@ -172,7 +173,7 @@ describe('POST /api/auth/sso/register', () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
queueTableRows(schemaMock.ssoDomain, []) // re-check before write: revoked
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
const json = await res.json()
expect(res.status).toBe(403)
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
@@ -185,7 +186,7 @@ describe('POST /api/auth/sso/register', () => {
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
const json = await res.json()
expect(res.status).toBe(403)
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
@@ -221,7 +222,7 @@ describe('POST /api/auth/sso/register', () => {
queueProviders([
{ domain: 'acme.com', userId: 'u1', organizationId: 'org1', providerId: 'acme-saml' },
])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
@@ -238,7 +239,7 @@ describe('POST /api/auth/sso/register', () => {
constraint_name: 'sso_provider_org_domain_unique',
})
)
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
@@ -248,14 +249,14 @@ describe('POST /api/auth/sso/register', () => {
it('lets the organization add a provider for a different verified domain', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', domain: 'eng.acme.com' }))
+ const res = await POST(request({ ...OIDC_BODY, domain: 'eng.acme.com' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
it('registers when the domain is unclaimed', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
@@ -286,7 +287,7 @@ describe('POST /api/auth/sso/register', () => {
it('does not treat the caller’s own provider as a providerId conflict', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
})
@@ -298,7 +299,7 @@ describe('POST /api/auth/sso/register', () => {
*/
it('marks the provider domain-verified after registering', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: true,
@@ -311,7 +312,7 @@ describe('POST /api/auth/sso/register', () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
@@ -346,7 +347,7 @@ describe('POST /api/auth/sso/register', () => {
},
]) // provider already owned → update path
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(403)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
// The conditional grant UPDATE is still issued — it simply matches no rows once
@@ -376,7 +377,7 @@ describe('POST /api/auth/sso/register', () => {
])
dbChainMockFns.returning.mockRejectedValueOnce(new Error('trust write failed'))
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
+ const res = await POST(request({ ...OIDC_BODY, jitProvisioningEnabled: false }))
expect(res.status).toBe(500)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
@@ -397,41 +398,28 @@ describe('POST /api/auth/sso/register', () => {
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
})
/**
- * A personal provider has no verified domain behind it. On the hosted
- * multi-tenant deployment that must grant no linking authority, or anyone able
- * to register one could claim a domain they do not own and have their own IdP
- * auto-link to existing accounts on it.
+ * An org-less provider has no `sso_domain` proof behind its domain, and domain
+ * trust is what auto-links an SSO sign-in into an existing same-email account,
+ * so the route refuses one before any write.
*/
- it('does not grant domain trust to a personal provider when hosted', async () => {
- setEnvFlags({ isSsoEnabled: true, isHosted: true })
- const res = await POST(request(OIDC_BODY))
- expect(res.status).toBe(200)
- expect(dbChainMockFns.set).toHaveBeenCalledWith({
- domainVerified: false,
- jitProvisioningEnabled: true,
- })
- })
-
- it('grants domain trust to a personal provider when self-hosted', async () => {
- setEnvFlags({ isSsoEnabled: true, isHosted: false })
- const res = await POST(request(OIDC_BODY))
- expect(res.status).toBe(200)
- expect(dbChainMockFns.set).toHaveBeenCalledWith({
- domainVerified: true,
- jitProvisioningEnabled: true,
- })
+ it('refuses a provider without an organization', async () => {
+ const { orgId: _orgId, ...orgLessBody } = OIDC_BODY
+ const res = await POST(request(orgLessBody))
+ expect(res.status).toBe(400)
+ expect((await res.json()).error).toContain('Organization ID is required')
+ expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('persists invite-only provisioning without changing Better Auth provider config', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
+ const res = await POST(request({ ...OIDC_BODY, jitProvisioningEnabled: false }))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: true,
@@ -516,9 +504,7 @@ describe('POST /api/auth/sso/register', () => {
it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- await POST(
- request({ ...OIDC_BODY, orgId: 'org1', mapping: { id: 'oid', email: 'upn', name: 'name' } })
- )
+ await POST(request({ ...OIDC_BODY, mapping: { id: 'oid', email: 'upn', name: 'name' } }))
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
expect(sent.mapping).toBeUndefined() // not passed at the top level (silently ignored there)
@@ -529,7 +515,7 @@ describe('POST /api/auth/sso/register', () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([]) // no providerId or domain conflicts on either pass
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.message).toContain('updated')
@@ -540,15 +526,15 @@ describe('POST /api/auth/sso/register', () => {
it('allows the owning tenant to update its own provider for the same domain', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
- it('lets an org admin adopt their own user-scoped provider for the same domain', async () => {
+ it("does not report the caller's own org-less provider as another tenant's claim", async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: null }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
@@ -556,14 +542,14 @@ describe('POST /api/auth/sso/register', () => {
it("still blocks an org admin from claiming another user's user-scoped domain", async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([{ domain: 'acme.com', userId: 'someone-else', organizationId: null }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(409)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})
it('normalizes the domain before persisting it', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' }))
+ const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
@@ -572,7 +558,7 @@ describe('POST /api/auth/sso/register', () => {
it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.skipDiscovery).toBe(true)
@@ -580,7 +566,7 @@ describe('POST /api/auth/sso/register', () => {
it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
+ const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
@@ -594,7 +580,7 @@ describe('POST /api/auth/sso/register', () => {
}
return { isValid: true, resolvedIP: '1.2.3.4' }
})
- const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
+ const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true }))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
@@ -624,7 +610,7 @@ describe('POST /api/auth/sso/register', () => {
jwksEndpoint: undefined,
skipUserInfoEndpoint: true,
}
- const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
+ const res = await POST(request(discoveredBody))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
@@ -632,7 +618,7 @@ describe('POST /api/auth/sso/register', () => {
it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.userInfoEndpoint).toBe('https://idp.acme.com/userinfo')
@@ -656,7 +642,7 @@ describe('POST /api/auth/sso/register', () => {
tokenEndpoint: undefined,
jwksEndpoint: undefined,
}
- const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
+ const res = await POST(request(discoveredBody))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -670,7 +656,7 @@ describe('POST /api/auth/sso/register', () => {
token_endpoint_auth_methods_supported: ['client_secret_post'],
}),
})
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -680,7 +666,7 @@ describe('POST /api/auth/sso/register', () => {
it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED'))
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.skipDiscovery).toBe(true)
@@ -696,7 +682,7 @@ describe('POST /api/auth/sso/register', () => {
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
}),
})
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -708,7 +694,7 @@ describe('POST /api/auth/sso/register', () => {
ok: true,
json: async () => ({}),
})
- const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
const config = mockRegisterSSOProvider.mock.calls[0][0].body
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -728,7 +714,7 @@ describe('POST /api/auth/sso/register', () => {
tokenEndpoint: undefined,
jwksEndpoint: undefined,
}
- const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
+ const res = await POST(request(discoveredBody))
const json = await res.json()
expect(res.status).toBe(400)
expect(json.error).toContain('resolves to a private IP address')
diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts
index c4991dc4271..913eccc4386 100644
--- a/apps/sim/app/api/auth/sso/register/route.ts
+++ b/apps/sim/app/api/auth/sso/register/route.ts
@@ -2,13 +2,14 @@ import { db, member, ssoDomain, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage, getPostgresConstraintName } from '@sim/utils/errors'
import { normalizeSSODomain } from '@sim/utils/sso-domain'
-import { and, eq, isNull, sql } from 'drizzle-orm'
+import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
-import { isHosted, isSsoEnabled } from '@/lib/core/config/env-flags'
+import { isSsoEnabled } from '@/lib/core/config/env-flags'
+import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
@@ -116,18 +117,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const body = parsed.data.body
const { providerId, issuer, providerType, mapping, orgId, jitProvisioningEnabled } = body
- if (orgId) {
- const [membership] = await db
- .select({ organizationId: member.organizationId, role: member.role })
- .from(member)
- .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId)))
- .limit(1)
- if (!membership) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
- if (membership.role !== 'owner' && membership.role !== 'admin') {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
+ /**
+ * Always org-scoped: an org-less provider has no `sso_domain` proof, so only
+ * operators create one, via `packages/db/scripts/register-sso-provider.ts`.
+ */
+ const [membership] = await db
+ .select({ organizationId: member.organizationId, role: member.role })
+ .from(member)
+ .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId)))
+ .limit(1)
+ if (!membership) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+ if (membership.role !== 'owner' && membership.role !== 'admin') {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const domain = normalizeSSODomain(body.domain)
@@ -142,20 +145,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
/**
* Configuring org SSO for a domain requires DNS-proven ownership; without it
* a first-come claim lets any org wire another company's domain to their own
- * IdP. Migration 0266 grandfathered existing domains. Org-less SSO is not gated.
+ * IdP. Migration 0266 grandfathered existing domains.
*/
+ const verifiedDomainClause = and(
+ eq(ssoDomain.organizationId, orgId),
+ eq(ssoDomain.domain, domain),
+ eq(ssoDomain.status, 'verified')
+ )
+
const isOrgDomainVerified = async (): Promise => {
- if (!orgId) return true
const [verified] = await db
.select({ id: ssoDomain.id })
.from(ssoDomain)
- .where(
- and(
- eq(ssoDomain.organizationId, orgId),
- eq(ssoDomain.domain, domain),
- eq(ssoDomain.status, 'verified')
- )
- )
+ .where(verifiedDomainClause)
.limit(1)
return Boolean(verified)
}
@@ -173,13 +175,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// window where the proof is removed while discovery is in flight.
if (!(await isOrgDomainVerified())) return domainNotVerifiedResponse()
+ /**
+ * An org-less provider the caller created counts as theirs, so its claim on
+ * a domain is not reported as another tenant's.
+ */
const isOwnedByCaller = (provider: {
userId: string | null
organizationId: string | null
- }): boolean => {
- if (provider.userId === session.user.id && !provider.organizationId) return true
- return orgId ? provider.organizationId === orgId : false
- }
+ }): boolean =>
+ provider.organizationId === orgId ||
+ (provider.userId === session.user.id && !provider.organizationId)
+
+ const ownerClause = and(
+ eq(ssoProvider.providerId, providerId),
+ eq(ssoProvider.organizationId, orgId)
+ )
/**
* Refuses the domain when another tenant has claimed it, or when the caller
@@ -236,10 +246,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const findProviderIdConflict = async () =>
(
await db
- .select({
- userId: ssoProvider.userId,
- organizationId: ssoProvider.organizationId,
- })
+ .select({ userId: ssoProvider.userId, organizationId: ssoProvider.organizationId })
.from(ssoProvider)
.where(eq(ssoProvider.providerId, providerId))
).find((provider) => !isOwnedByCaller(provider))
@@ -274,7 +281,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
providerId,
issuer,
domain,
- ...(orgId ? { organizationId: orgId } : {}),
+ organizationId: orgId,
}
if (providerType === 'oidc') {
@@ -292,13 +299,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
let clientSecret = rawClientSecret
if (rawClientSecret === REDACTED_MARKER) {
- const ownerClause = orgId
- ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId))
- : and(
- eq(ssoProvider.providerId, providerId),
- eq(ssoProvider.userId, session.user.id),
- isNull(ssoProvider.organizationId)
- )
const [existing] = await db
.select({ oidcConfig: ssoProvider.oidcConfig })
.from(ssoProvider)
@@ -370,7 +370,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
- const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl)
+ const discoveryResult = await runWithOutboundOrganization(membership.organizationId, () =>
+ fetchOIDCDiscoveryDocument(discoveryUrl)
+ )
if (needsDiscovery) {
logger.info('Fetching OIDC discovery document for missing endpoints', {
@@ -632,18 +634,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// edit through updateSSOProvider so re-saving an SSO config works instead of
// failing. The verification gate above already ran against the target domain,
// so an edit that moves SSO to an unverified domain is still blocked.
- // The personal branch MUST require a null org: org providers store
- // userId = their creator, so without it an org admin could send a
- // personal-mode request (which skips the membership check and the
- // verification gate) yet still match — and then update — their org's
- // provider, moving it to an unverified domain. Mirrors isOwnedByCaller.
- const ownerClause = orgId
- ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId))
- : and(
- eq(ssoProvider.providerId, providerId),
- eq(ssoProvider.userId, session.user.id),
- isNull(ssoProvider.organizationId)
- )
// Config columns are captured, not just the id: an update whose trust grant is
// refused has to be undone, or the rejected config stays stored and goes live
// the moment the domain is verified again.
@@ -668,29 +658,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
* UPDATE waits can still grant trust after ownership is gone. `FOR SHARE`
* orders the two — the delete blocks until this commits, and if it committed
* first the SELECT finds nothing.
- *
- * Org-less SSO is self-host-only (Sim's UI always registers org-scoped) and
- * has no proof behind it, so it is trusted only when self-hosted.
*/
- const grantProviderDomainTrust = async (): Promise => {
- if (!orgId) {
- await db
- .update(ssoProvider)
- .set({ domainVerified: !isHosted, jitProvisioningEnabled })
- .where(ownerClause)
- return true
- }
- return db.transaction(async (tx) => {
+ const grantProviderDomainTrust = (): Promise =>
+ db.transaction(async (tx) => {
const [proof] = await tx
.select({ id: ssoDomain.id })
.from(ssoDomain)
- .where(
- and(
- eq(ssoDomain.organizationId, orgId),
- eq(ssoDomain.domain, domain),
- eq(ssoDomain.status, 'verified')
- )
- )
+ .where(verifiedDomainClause)
.limit(1)
.for('share')
if (!proof) return false
@@ -702,7 +676,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.returning({ id: ssoProvider.id })
return granted.length > 0
})
- }
if (existingOwnedProvider) {
const revertProviderUpdate = async (): Promise => {
@@ -784,12 +757,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// registerSSOProvider spreads the created row's `id` at runtime, but the
// typed return omits it — read it defensively and only delete when it's a
// real id, so a future shape change can't turn the rollback into a silent
- // no-op that leaves a provider on an unverified domain. `orgId` is checked
- // only to narrow it: the org-less path grants unconditionally, so a refused
- // grant always means an org-scoped registration.
+ // no-op that leaves a provider on an unverified domain.
// double-cast-allowed: Better Auth's return type omits the runtime `id`
const createdRowId = (registration as unknown as { id?: unknown }).id
- if (orgId && typeof createdRowId === 'string' && createdRowId.length > 0) {
+ if (typeof createdRowId === 'string' && createdRowId.length > 0) {
await db
.delete(ssoProvider)
.where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId)))
diff --git a/apps/sim/app/api/knowledge/member-connectors/route.ts b/apps/sim/app/api/knowledge/member-connectors/route.ts
index 4467c3ec35e..3b7d453ea6e 100644
--- a/apps/sim/app/api/knowledge/member-connectors/route.ts
+++ b/apps/sim/app/api/knowledge/member-connectors/route.ts
@@ -12,7 +12,9 @@ export const GET = defineInternalJsonRoute({
contract: listWorkspaceMemberConnectorsContract,
auth: internalSessionAuth,
operation: knowledgeOperations.listWorkspaceMemberConnectors,
- rateLimit: internalRateLimits.none({ reason: 'One small read per visit to the Search tab' }),
+ rateLimit: internalRateLimits.none({
+ reason: 'Preserve existing internal connector listing behavior',
+ }),
errorPolicy: internalKnowledgeErrorPolicies.connectors,
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
useCase: listWorkspaceMemberConnectors,
diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts
index e02b67e4203..445fe171c63 100644
--- a/apps/sim/app/api/knowledge/search/route.test.ts
+++ b/apps/sim/app/api/knowledge/search/route.test.ts
@@ -19,7 +19,11 @@ describe('workspace search route', () => {
user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' },
session: { id: 'session-1' },
})
- mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] })
+ mocks.search.mockResolvedValue({
+ results: [],
+ knowledgeBases: [],
+ retrieval: { status: 'complete', timedOutLegs: [] },
+ })
})
it('passes the authenticated request cancellation signal through the existing operation', async () => {
@@ -41,11 +45,49 @@ describe('workspace search route', () => {
expect(call.input).not.toHaveProperty('knowledgeBaseIds')
expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] })
expect(call.input.signal).toBe(request.signal)
+ expect(call.input.allowPartialResults).toBe(true)
+ expect(call.input.vectorBudgetMs).toBe(3000)
controller.abort()
expect(call.input.signal.aborted).toBe(true)
await expect(response.json()).resolves.toEqual({
success: true,
- data: { query: 'Orion', results: [] },
+ data: { query: 'Orion', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
+ })
+ })
+
+ it('preserves usable matches and incomplete coverage when one retrieval leg times out', async () => {
+ mocks.search.mockResolvedValueOnce({
+ knowledgeBases: [{ id: 'knowledge-1', name: 'Search index' }],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ results: [
+ {
+ documentId: 'document-1',
+ knowledgeBaseId: 'knowledge-1',
+ documentName: 'Release plan',
+ sourceUrl: null,
+ connectorType: null,
+ sourceModifiedAt: null,
+ metadata: {},
+ content: 'Orion release',
+ chunkIndex: 0,
+ similarity: 0.9,
+ },
+ ],
+ })
+ const response = await POST(
+ new NextRequest('http://localhost/api/knowledge/search', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ organizationId: 'organization-1', query: 'Orion' }),
+ })
+ )
+ expect(response.status).toBe(200)
+ await expect(response.json()).resolves.toMatchObject({
+ success: true,
+ data: {
+ results: [{ documentId: 'document-1', content: 'Orion release' }],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ },
})
})
diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts
index cc9ae79fb8b..b1db5e04d39 100644
--- a/apps/sim/app/api/knowledge/search/route.ts
+++ b/apps/sim/app/api/knowledge/search/route.ts
@@ -9,6 +9,8 @@ import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
import { sourceAuthor } from '@/lib/knowledge/search/author'
+const DIRECT_SEARCH_VECTOR_BUDGET_MS = 3000
+
export const POST = defineInternalJsonRoute({
contract: searchWorkspaceKnowledgeContract,
auth: internalSessionAuth,
@@ -24,16 +26,19 @@ export const POST = defineInternalJsonRoute({
filters: body.filters,
query: body.query,
topK: body.topK,
+ allowPartialResults: true,
+ vectorBudgetMs: DIRECT_SEARCH_VECTOR_BUDGET_MS,
surface: 'dashboard' as const,
signal: request.signal,
}),
useCase: searchScopedKnowledge,
- present: ({ results, knowledgeBases }, { input }) => {
+ present: ({ results, knowledgeBases, retrieval }, { input }) => {
const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name]))
return {
success: true as const,
data: {
query: input.query ?? '',
+ retrieval,
results: results.map((result) => ({
documentId: result.documentId,
knowledgeBaseId: result.knowledgeBaseId,
diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts
index 8aaba14c0db..27e810a593a 100644
--- a/apps/sim/app/api/link-preview/route.ts
+++ b/apps/sim/app/api/link-preview/route.ts
@@ -10,6 +10,7 @@ import { getLinkPreviewContract } from '@/lib/api/contracts/link-preview'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getRedisClient } from '@/lib/core/config/redis'
+import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -104,7 +105,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
let preview: LinkPreview = null
try {
- preview = await fetchPreview(url)
+ /** Link previews have no organization owner and use a shared URL cache. */
+ preview = await runWithOutboundOrganization(null, () => fetchPreview(url))
} catch (error) {
logger.info('Link preview fetch failed; returning null preview', {
host: new URL(url).hostname,
diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts
index dd31f4a24b2..6b7655ec2b0 100644
--- a/apps/sim/app/api/mcp/oauth/callback/route.ts
+++ b/apps/sim/app/api/mcp/oauth/callback/route.ts
@@ -8,6 +8,7 @@ import { NextResponse } from 'next/server'
import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment'
@@ -238,11 +239,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const provider = new SimMcpOauthProvider({ row, preregistered })
let result: Awaited>
try {
- result = await timedStep('mcpAuthGuarded', 120_000, () =>
- mcpAuthGuarded(provider, {
- serverUrl,
- authorizationCode: code,
- })
+ result = await withResourceOutboundScope({ workspaceId: serverWorkspaceId }, () =>
+ timedStep('mcpAuthGuarded', 120_000, () =>
+ mcpAuthGuarded(provider, { serverUrl, authorizationCode: code })
+ )
)
} catch (e) {
logger.error('Token exchange failed during MCP OAuth callback', e)
@@ -267,7 +267,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
// forceRefresh: skip any stale cache from before re-auth.
await timedStep('discoverServerTools', 60_000, () =>
- mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
+ withResourceOutboundScope({ workspaceId: serverWorkspaceId }, () =>
+ mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force')
+ )
)
} catch (e) {
logger.warn('Post-auth tools refresh failed', toError(e).message)
diff --git a/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts
index 5550ff9eb4c..c14d01b3c02 100644
--- a/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts
+++ b/apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts
@@ -4,6 +4,7 @@ import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { testDataDrainContract } from '@/lib/api/contracts/data-drains'
import { parseRequest } from '@/lib/api/server'
+import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
import { getDestination } from '@/lib/data-drains/destinations/registry'
@@ -29,7 +30,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
}
const destination = getDestination(drain.destinationType)
- if (!destination.test) {
+ const testConnection = destination.test
+ if (!testConnection) {
return NextResponse.json(
{ error: `Destination '${drain.destinationType}' does not support connection testing` },
{ status: 400 }
@@ -44,7 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS)
try {
- await destination.test({ config, credentials, signal: controller.signal })
+ await runWithOutboundOrganization(drain.organizationId, () =>
+ testConnection({ config, credentials, signal: controller.signal })
+ )
recordAudit({
workspaceId: null,
actorId: access.session.user.id,
diff --git a/apps/sim/app/api/organizations/[id]/network/route.ts b/apps/sim/app/api/organizations/[id]/network/route.ts
new file mode 100644
index 00000000000..a6e87d2cb75
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/network/route.ts
@@ -0,0 +1,24 @@
+import { getOrganizationNetworkContract } from '@/lib/api/contracts/organization-network'
+import {
+ defineInternalJsonRoute,
+ internalOrchestrationErrorPolicy,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import {
+ readOrganizationNetwork,
+ readOrganizationNetworkOperation,
+} from '@/lib/core/network/application/read-organization-network'
+
+export const dynamic = 'force-dynamic'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationNetworkContract,
+ auth: internalSessionAuth,
+ operation: readOrganizationNetworkOperation,
+ rateLimit: internalRateLimits.user({ bucketName: 'organization-network-read' }),
+ errorPolicy: internalOrchestrationErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: readOrganizationNetwork,
+ staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
+})
diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts
index 3971a4c4ca5..7e847cb2797 100644
--- a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts
+++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts
@@ -59,7 +59,10 @@ vi.mock('@/lib/knowledge/embeddings', () => ({
vi.mock('@/lib/knowledge/search/queries', () => ({
generateSearchEmbedding: mocks.generateEmbedding,
- executeKnowledgeSearch: mocks.executeSearch,
+ retrieveKnowledgeSearch: async (...args: unknown[]) => ({
+ rows: await mocks.executeSearch(...args),
+ retrieval: { status: 'complete', timedOutLegs: [] },
+ }),
getDocumentMetadataByIds: mocks.getDocumentMetadata,
}))
diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
index 2ef014a73a6..39bfb5f40e5 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx
@@ -225,9 +225,9 @@ describe('focused Search enrollment', () => {
expect(document.querySelector('form')).toBeNull()
expect(
Array.from(document.querySelectorAll('a'))
- .find((link) => link.textContent === 'Return to Search')
+ .find((link) => link.textContent === 'Open knowledge bases')
?.getAttribute('href')
- ).toBe('/workspace/canonical-workspace/search')
+ ).toBe('/workspace/canonical-workspace/knowledge')
expect(document.body.textContent).not.toContain('Setup guide')
expect(mocks.read).toHaveBeenCalledWith({ principal, input: { optionId: 'site-two' } })
})
@@ -243,7 +243,7 @@ describe('focused Search enrollment', () => {
expect(document.body.textContent).toContain('Ask an admin')
expect(oauthLinks()).toHaveLength(0)
expect(document.querySelector('form')).toBeNull()
- expect(document.body.textContent).toContain('Return to Search')
+ expect(document.body.textContent).toContain('Open knowledge bases')
}
)
@@ -255,7 +255,7 @@ describe('focused Search enrollment', () => {
expect(document.body.textContent).toContain('Connection unavailable')
expect(document.body.textContent).toContain('Ask an admin')
expect(document.querySelector('a')?.getAttribute('href')).toBe(
- '/workspace/canonical-workspace/search'
+ '/workspace/canonical-workspace/knowledge'
)
})
@@ -274,7 +274,7 @@ describe('focused Search enrollment', () => {
expect(document.querySelector('h1')?.textContent).toBe('Confluence connected')
expect(oauthLinks()).toHaveLength(0)
expect(document.querySelector('form')).toBeNull()
- expect(document.body.textContent).toContain('Return to Search')
+ expect(document.body.textContent).toContain('Open knowledge bases')
})
it('does not treat a success query marker as a connected account', async () => {
diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
index c819e8c122a..4550345da53 100644
--- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx
+++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx
@@ -184,8 +184,12 @@ export default async function CredentialGroupEnrollmentPage({
const canReturnToSearch =
returnToSearch &&
('canSearch' in enrollmentResult ? enrollmentResult.canSearch : !principal.organizationId)
- const returnHref = canReturnToSearch ? searchReturnPath(principal) : APP_ENTRY_PATH
- const returnLabel = canReturnToSearch ? 'Return to Search' : 'Open Sim'
+ const returnHref = canReturnToSearch ? sourceReturnPath(principal) : APP_ENTRY_PATH
+ const returnLabel = canReturnToSearch
+ ? principal.organizationId
+ ? 'Return to Search'
+ : 'Open knowledge bases'
+ : 'Open Sim'
if (!enrollment)
return
@@ -328,9 +332,9 @@ export default async function CredentialGroupEnrollmentPage({
)
}
-function searchReturnPath(owner: ResourceOwner): string {
+function sourceReturnPath(owner: ResourceOwner): string {
const scope = resourceScopeFromOwner(owner)
return scope.kind === 'workspace'
- ? `/workspace/${encodeURIComponent(scope.workspaceId)}/search`
+ ? `/workspace/${encodeURIComponent(scope.workspaceId)}/knowledge`
: organizationRoutes(scope.organizationId).search
}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx
index 8a39e94a9fe..0d1077648d7 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx
@@ -5,19 +5,27 @@ import { act, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockNavigate, mockPush } = vi.hoisted(() => ({
+const { mockNavigate, mockPush, context } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockPush: vi.fn(),
+ context: {
+ organization: { id: 'org-1' },
+ },
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
+ usePathname: () => '/o/org-1/home',
}))
vi.mock('next/link', () => ({
default: ({
onNavigate,
+ prefetch: _prefetch,
...props
- }: ComponentProps<'a'> & { onNavigate?: (event: { preventDefault: () => void }) => void }) => (
+ }: ComponentProps<'a'> & {
+ prefetch?: boolean
+ onNavigate?: (event: { preventDefault: () => void }) => void
+ }) => (
({
/>
),
}))
+vi.mock('@/lib/auth/sign-out', () => ({ signOutAndRedirect: vi.fn() }))
vi.mock('@/lib/desktop', () => ({ getDesktopUpdates: () => null }))
vi.mock('@/hooks/use-desktop-update-state', () => ({
useDesktopUpdateState: () => ({ status: 'idle' }),
@@ -42,12 +51,14 @@ vi.mock('@/hooks/queries/user-profile', () => ({
useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@example.com' } }),
}))
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
- useOrganizationContext: () => ({ organization: { id: 'org-1' } }),
+ useOrganizationContext: () => context,
}))
vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/components', () => ({
SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
}))
-vi.mock('@/components/icons', () => ({ SlackIcon: () => }))
+vi.mock('@/components/icons', () => ({
+ SlackIcon: () => ,
+}))
import { OrganizationFooter } from '@/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
@@ -71,7 +82,7 @@ afterEach(async () => {
vi.unstubAllGlobals()
})
-async function selectSettings() {
+async function openProfileMenu() {
await act(async () => {
root.render(
{}}
onJoinSlack={() => {}}
+ onContactSupport={() => {}}
/>
)
})
@@ -88,15 +100,27 @@ async function selectSettings() {
await act(async () => {
trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
})
+}
+
+async function selectSettings() {
+ await openProfileMenu()
const link = document.querySelector('a[href="/o/org-1/settings/general"]')
if (!link) throw new Error('Settings link is missing')
await act(async () => link.click())
}
describe('OrganizationFooter settings navigation', () => {
+ it('keeps only Settings and Sign out in the organization profile menu', async () => {
+ await openProfileMenu()
+ expect(
+ [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
+ ).toEqual(['Settings', 'Sign out'])
+ expect(document.querySelector('[role="separator"]')).toBeNull()
+ })
+
it('navigates immediately when settings are clean', async () => {
await selectSettings()
- expect(mockNavigate).toHaveBeenCalledWith('/o/org-1/settings/general')
+ expect(mockPush).toHaveBeenCalledWith('/o/org-1/settings/general')
expect(useSettingsDirtyStore.getState().pendingLeave).toBeNull()
})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
index ecc37a12e01..125ab4202af 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
@@ -1,242 +1,28 @@
'use client'
-import type { DesktopUpdateState } from '@sim/desktop-bridge'
-import {
- Chip,
- chipContentLabelClass,
- chipPrimaryFillTokens,
- chipVariants,
- cn,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuItemLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- OverflowText,
- Skeleton,
-} from '@sim/emcn'
-import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons'
-import { SlackIcon } from '@/components/icons'
-import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
-import { getDesktopUpdates } from '@/lib/desktop'
+import type { ComponentProps } from 'react'
+import { useRouter } from 'next/navigation'
import { organizationRoutes } from '@/lib/navigation/paths'
-import { getUserColor } from '@/lib/workspaces/colors'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
-import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
-import {
- SIDEBAR_ITEM_GAP_CLASS,
- SIDEBAR_RAIL_CHIP_CLASS,
-} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { useUserProfile } from '@/hooks/queries/user-profile'
-import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
+import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer'
-function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean {
- return state.status === 'available' || state.status === 'downloading' || state.status === 'ready'
-}
-
-function desktopUpdateActionLabel(state: DesktopUpdateState): string {
- if (state.status === 'downloading') {
- return state.percent === undefined
- ? 'Downloading update…'
- : `Downloading update ${state.percent}%`
- }
- return state.status === 'ready' ? 'Restart to update' : 'Update'
-}
-
-/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */
-function DesktopUpdateIcon({ className }: { className?: string }) {
- return (
-
- {/* Download's default viewBox is asymmetric around its paths. Center the
- artwork itself, not merely its SVG box, inside the avatar-sized circle. */}
-
-
- )
-}
+interface OrganizationFooterProps
+ extends Omit<
+ ComponentProps,
+ 'accountSettingsHref' | 'onOpenAccountSettings' | 'navigationLinks'
+ > {}
-interface OrganizationFooterProps {
- /**
- * True while the scroll region above still hides rows beyond its bottom edge —
- * the same test the divider under the pinned nav applies at the top. The bar's
- * top rule is drawn only then, so a list that fits meets the footer with no line.
- */
- showDivider: boolean
- isCollapsed: boolean
- showCollapsedTooltips: boolean
- onOpenDocs: () => void
- onJoinSlack: () => void
-}
-
-/**
- * Pinned bottom bar of the organization sidebar: the viewer's avatar and name,
- * which open their account settings, plus a help menu. Same two elements and the
- * same layout as the workspace footer — expanded they share one row with help hard
- * right, collapsed they stack as icon chips with help on top.
- *
- * Collapsed reverses the flex direction instead of reordering the DOM, which keeps
- * both elements (and the help menu's trigger) alive across a toggle.
- */
-export function OrganizationFooter({
- showDivider,
- isCollapsed,
- showCollapsedTooltips,
- onOpenDocs,
- onJoinSlack,
-}: OrganizationFooterProps) {
+export function OrganizationFooter(props: OrganizationFooterProps) {
const { organization } = useOrganizationContext()
- const { data: profile } = useUserProfile()
- const updateState = useDesktopUpdateState()
-
- const name = profile ? profile.name?.trim() || profile.email : ''
- const updateAvailable = hasAvailableDesktopUpdate(updateState)
-
- const handleUpdateSelect = () => {
- const updates = getDesktopUpdates()
- if (updateState.status === 'ready') {
- updates?.install()
- } else if (updateState.status === 'available') {
- updates?.check()
- }
- }
-
- /**
- * Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a
- * `` — and globals fade every `span` in the collapsed rail to `opacity: 0`,
- * which would blank the avatar exactly where it is the only thing left to see.
- */
- const avatar = !profile ? (
-
- ) : profile.image ? (
-
- ) : (
-
- {name.charAt(0).toUpperCase()}
-
- )
-
- /**
- * Expanded, the chip hugs its content (`max-w-full` so a long name truncates
- * rather than overflowing); collapsed, `fullWidth` fills the narrow rail and
- * `min-w-0` lets the hidden label give up its box so the chip never overflows it.
- * The name is the button's accessible name — no `aria-label`, which would
- * override the visible text.
- */
- const profileMenu = (
-
-
-
-
- {avatar}
- {profile ? (
-
- ) : (
- /* Fixed width — the chip hugs its content, so a flexible bar would collapse to nothing. */
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- )
-
- /**
- * One node across both states; only `fullWidth` changes, so the same Radix menu
- * survives the transition. `shrink-0` keeps the chip off the avatar while the rail
- * is briefly narrower than the row — the aside's clip hides it until there is room.
- */
- const helpMenu = (
-
-
-
-
-
-
- {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */}
-
- {updateAvailable && (
- <>
-
-
- {desktopUpdateActionLabel(updateState)}
-
-
- >
- )}
-
-
- Docs
-
-
-
- Join Slack
-
-
-
- )
+ const router = useRouter()
+ const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')
return (
-
- {/* Expanded, claims the row's free width so the help button lands hard right.
- `flex` makes the inline-flex chip a flex item, so the wrapper is exactly the
- chip's 30px rather than a line box padded by the strut's half-leading. */}
-
{profileMenu}
- {helpMenu}
-
+ router.push(accountSettingsHref)}
+ navigationLinks={[]}
+ />
)
}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx
index 665eea639dc..e3d69feee22 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.test.tsx
@@ -7,7 +7,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({ upload: vi.fn(), refresh: vi.fn() }))
+const mocks = vi.hoisted(() => ({ upload: vi.fn(), refresh: vi.fn(), invite: vi.fn() }))
+vi.mock('@/app/workspace/[workspaceId]/components/invite-modal', () => ({
+ InviteModal: (props: object) => {
+ mocks.invite(props)
+ return null
+ },
+}))
vi.mock('@/lib/uploads/client/session-upload', () => ({
uploadInternalFileSession: mocks.upload,
}))
@@ -49,7 +55,12 @@ afterEach(async () => {
vi.unstubAllGlobals()
})
-async function render(canEditLogo = true, isCollapsed = false, onExpandSidebar = vi.fn()) {
+async function render(
+ canEditLogo = true,
+ isCollapsed = false,
+ onExpandSidebar = vi.fn(),
+ canInviteMembers = canEditLogo
+) {
await act(async () => {
root.render(
@@ -57,6 +68,7 @@ async function render(canEditLogo = true, isCollapsed = false, onExpandSidebar =
@@ -189,3 +201,35 @@ describe('OrganizationHeader logo upload', () => {
expect(mocks.refresh).toHaveBeenCalledOnce()
})
})
+
+describe('OrganizationHeader member actions', () => {
+ it('opens the existing invitation flow for the current organization', async () => {
+ await render()
+ await openMenu()
+ const invite = [...document.querySelectorAll('[role="menuitem"]')].find(
+ (item) => item.textContent === 'Invite people'
+ )!
+ await act(async () => invite.click())
+ expect(mocks.invite).toHaveBeenCalledWith(
+ expect.objectContaining({
+ open: true,
+ organizationId: 'org-1',
+ isOrganizationAdmin: true,
+ canInvite: true,
+ })
+ )
+ })
+
+ it.each([false, true])(
+ 'keeps settings access but hides disallowed invitations (admin=%s)',
+ async (admin) => {
+ await render(admin, false, vi.fn(), false)
+ await openMenu()
+ expect(document.querySelector('a[href="/o/org-1/settings/members"]')).toHaveTextContent(
+ 'Settings'
+ )
+ expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Invite people')
+ expect(mocks.invite).not.toHaveBeenCalled()
+ }
+ )
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
index 19a580ae22b..3075628dbc7 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useRef } from 'react'
+import { useRef, useState } from 'react'
import {
Chip,
ChipChevronDown,
@@ -12,13 +12,14 @@ import {
Tooltip,
toast,
} from '@sim/emcn'
-import { PanelLeft, Settings } from '@sim/emcn/icons'
+import { PanelLeft, Send, Settings } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link'
import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface'
import { LOGO_ACCEPT_ATTRIBUTE } from '@/lib/uploads/client/logo-file'
+import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { useUploadOrganizationLogo } from '@/hooks/queries/organization-logo'
@@ -29,6 +30,7 @@ function getOrganizationInitial(name: string): string {
interface OrganizationHeaderProps {
organization: OrganizationSurfaceOrganization
canEditLogo: boolean
+ canInviteMembers: boolean
isCollapsed: boolean
/** Expands the rail; the collapsed header is itself the expand control. */
onExpandSidebar: () => void
@@ -44,6 +46,7 @@ interface OrganizationHeaderProps {
export function OrganizationHeader({
organization,
canEditLogo,
+ canInviteMembers,
isCollapsed,
onExpandSidebar,
}: OrganizationHeaderProps) {
@@ -52,6 +55,7 @@ export function OrganizationHeader({
const { mutate: uploadLogo, isPending: isUploadingLogo } = useUploadOrganizationLogo(
organization.id
)
+ const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const initial = getOrganizationInitial(organization.name)
if (isCollapsed) {
@@ -131,7 +135,7 @@ export function OrganizationHeader({
aria-label='Change organization logo'
aria-busy={isUploadingLogo}
textValue='Change organization logo'
- className='h-auto shrink-0 p-1'
+ className='h-auto shrink-0 p-0 hover-hover:opacity-70 focus-visible:opacity-70'
disabled={isUploadingLogo}
onSelect={(event) => {
event.preventDefault()
@@ -161,8 +165,23 @@ export function OrganizationHeader({
Settings
+ {canInviteMembers && (
+ setIsInviteModalOpen(true)}>
+
+ Invite people
+
+ )}
+ {isInviteModalOpen && (
+
+ )}
)
}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx
index e4ce1404ee2..2a08c775fb9 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx
@@ -45,6 +45,22 @@ vi.mock('@/hooks/queries/workspace', () => ({
}))
import { WorkspaceList } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list'
+import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu'
+
+function KeyboardWorkspaceFlyout() {
+ const hover = useHoverMenu()
+ return (
+ (open ? hover.open() : hover.close())}
+ >
+ Workspaces
+
+
+
+
+ )
+}
let container: HTMLDivElement
let root: Root
@@ -91,6 +107,18 @@ async function render() {
}
describe('WorkspaceList rail view', () => {
+ it('keeps the flyout open when opened from the keyboard without pointer hover', async () => {
+ workspacesState.workspaces = [{ id: 'ws-1', name: 'Design' }]
+ await act(async () => root.render( ))
+ const trigger = container.querySelector('button')!
+ await act(async () => {
+ trigger.focus()
+ trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
+ })
+ expect(document.querySelector('[role="menu"]')).not.toBeNull()
+ expect(document.querySelector('a[href="/workspace/ws-1"]')).toHaveTextContent('Design')
+ })
+
it('lists every workspace as a link into it', async () => {
workspacesState.workspaces = [
{ id: 'ws-1', name: 'Design' },
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
index c0085b6b92c..8d1bfa0b942 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx
@@ -52,11 +52,13 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis
},
})
const lockFlyout = flyout?.setLocked
+ const isInteracting = menu.isOpen || rename.editingId !== null
useEffect(() => {
- lockFlyout?.(menu.isOpen || rename.editingId !== null)
- return () => lockFlyout?.(false)
- }, [lockFlyout, menu.isOpen, rename.editingId])
+ if (!lockFlyout || !isInteracting) return
+ lockFlyout(true)
+ return () => lockFlyout(false)
+ }, [lockFlyout, isInteracting])
const visibleWorkspaces = flyout ? workspaces : workspaces.slice(0, visibleCount)
const hasMore = workspaces.length > visibleCount
@@ -90,7 +92,11 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis
initial={getWorkspaceInitial(workspace.name)}
logoUrl={workspace.logoUrl}
/>
-
+
>
)
const onMoreClick = (event: React.MouseEvent) => {
@@ -136,6 +142,16 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis
onPointerMove={(event) => {
if (menu.isOpen || rename.editingId) event.preventDefault()
}}
+ actionIndicator={
+ isPinned ? (
+
+ ) : undefined
+ }
action={
openMenu(event, workspace.id)}
>
{label}
- {isPinned && }
)
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
index 363e229f6ec..c4434335f55 100644
--- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
@@ -27,6 +27,7 @@ import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/works
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
+ HelpModal,
isNavItemActive,
NavItemContextMenu,
SidebarNavChip,
@@ -94,6 +95,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() {
const settingsPath = organizationRoutes(organization.id).settings
const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`)
+ const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
const [menuHref, setMenuHref] = useState(null)
const {
isOpen: isHrefMenuOpen,
@@ -182,6 +184,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() {
@@ -282,6 +285,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() {
showCollapsedTooltips={showCollapsedTooltips}
onOpenDocs={handleOpenDocs}
onJoinSlack={handleOpenSlackCommunity}
+ onContactSupport={() => setIsHelpModalOpen(true)}
/>
+
+
{/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that
out-specifies the `[data-peek]` rule, stranding the card at a stale width. */}
{!isPeeking && (
diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx
index b29d1b481cc..9db4e9751da 100644
--- a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx
@@ -11,30 +11,9 @@ vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-show
IntegrationTile: () => null,
}))
-import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors'
import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu'
-import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row'
const accounts = [{ credentialId: 'my-gmail', displayName: 'me@example.test' }]
-const source: SearchSourceSummary = {
- knowledgeBaseId: 'kb',
- connectorId: 'gmail',
- connectorType: 'gmail',
- sourceDescription: '',
- accessMode: 'members',
- availability: 'available',
- enabled: true,
- isSyncing: true,
- lastSyncAt: null,
- hasSyncError: false,
- viewerDocumentCount: 0,
- viewerFailedDocumentCount: 0,
- viewerEmailVerified: true,
- connectionRequired: true,
- viewerMembership: 'connected',
- viewerAccounts: accounts,
-}
-
describe('personal integration disconnect', () => {
let root: Root
let container: HTMLDivElement
@@ -56,25 +35,10 @@ describe('personal integration disconnect', () => {
container.remove()
vi.unstubAllGlobals()
})
- async function render(overrides: Partial = {}) {
+ async function render() {
await act(async () =>
root.render(
-
- }
- />
+
)
)
}
@@ -96,15 +60,8 @@ describe('personal integration disconnect', () => {
)!
}
- it.each([
- ['indexing', {}],
- ['failed', { hasSyncError: true }],
- ['paused', { enabled: false }],
- ['deactivated', { approved: false }],
- ['reconnect', { viewerMembership: 'needs_reauth' }],
- ['unavailable', { availability: 'unavailable', viewerMembership: null }],
- ] as const)('allows disconnect while %s without requiring admin access', async (_, overrides) => {
- await render(overrides)
+ it('requires confirmation before disconnecting an account', async () => {
+ await render()
await openDisconnect()
expect(document.body.textContent).toContain(
'Disconnect me@example.test from all Gmail connections in this organization.'
diff --git a/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx b/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx
index 70c9061b950..a80ca7184f1 100644
--- a/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx
+++ b/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx
@@ -3,9 +3,9 @@
import { Chip, ChipLink } from '@sim/emcn'
import { organizationRoutes } from '@/lib/navigation/paths'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
+import { getSearchSourceStatus } from '@/lib/sim-search/source-status'
import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import { getSearchSourceStatus } from '@/app/workspace/[workspaceId]/search/components/search-source-row-status'
import type { RowAction } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
diff --git a/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx b/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx
index ebabd37f6fc..6cd806b52bc 100644
--- a/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx
+++ b/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/page.tsx
@@ -1,16 +1,21 @@
import { ChipLink } from '@sim/emcn'
import { notFound, redirect } from 'next/navigation'
+import type { SearchParams } from 'nuqs/server'
import { readSearchDocumentResultSchema } from '@/lib/api/contracts/knowledge/documents'
import { getSession } from '@/lib/auth'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { readSearchDocument } from '@/lib/knowledge/application/read-search-document'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import {
+ loadDocumentReadParams,
+ serializeDocumentReadParams,
+} from '@/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/search-params'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
interface OrganizationDocumentPageProps {
params: Promise<{ organizationId: string; knowledgeBaseId: string; documentId: string }>
- searchParams: Promise<{ offset?: string }>
+ searchParams: Promise
}
export default async function OrganizationDocumentPage({
@@ -18,15 +23,15 @@ export default async function OrganizationDocumentPage({
searchParams,
}: OrganizationDocumentPageProps) {
const { organizationId, knowledgeBaseId, documentId } = await params
- const { offset: rawOffset } = await searchParams
- const offset = rawOffset === undefined ? 0 : Number(rawOffset)
- if (!Number.isInteger(offset) || offset < 0 || offset > 5000) notFound()
+ const position = await loadDocumentReadParams(searchParams, { strict: true }).catch(() =>
+ notFound()
+ )
const href = `/o/${encodeURIComponent(organizationId)}/knowledge/${encodeURIComponent(knowledgeBaseId)}/${encodeURIComponent(documentId)}`
const session = await getSession()
if (!session?.user) {
redirect(
buildAuthCrossLink('/login', {
- callbackUrl: offset ? `${href}?offset=${offset}` : href,
+ callbackUrl: serializeDocumentReadParams(href, position),
isInviteFlow: false,
})
)
@@ -39,15 +44,15 @@ export default async function OrganizationDocumentPage({
input: {
documentId,
assertedOrganizationId: organizationId,
- offset,
- limit: 20,
+ ...position,
+ limit: 3,
resultSecretRegistry: registry,
},
})
} catch (error) {
if (
error instanceof OrchestrationError &&
- (error.code === 'not_found' || error.code === 'forbidden')
+ (error.code === 'not_found' || error.code === 'forbidden' || error.code === 'validation')
)
notFound()
throw error
@@ -71,11 +76,11 @@ export default async function OrganizationDocumentPage({
))}
- {offset > 0 && (
- Previous
+ {(position.startChunkIndex > 0 || position.startOffset > 0) && (
+ Start
)}
- {document.nextOffset !== null && (
- Next
+ {document.next && (
+ Next
)}
diff --git a/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/search-params.ts b/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/search-params.ts
new file mode 100644
index 00000000000..42f9dd224e8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/knowledge/[knowledgeBaseId]/[documentId]/search-params.ts
@@ -0,0 +1,25 @@
+import { createLoader, createParser, createSerializer } from 'nuqs/server'
+
+const parseAsDocumentPosition = createParser({
+ parse: (value) => {
+ if (!/^\d+$/.test(value)) return null
+ const position = Number(value)
+ return Number.isSafeInteger(position) && position <= 2147483647 ? position : null
+ },
+ serialize: String,
+}).withDefault(0)
+
+export const documentReadParams = {
+ startChunkIndex: parseAsDocumentPosition,
+ startOffset: parseAsDocumentPosition,
+}
+
+const documentReadUrlKeys = {
+ urlKeys: {
+ startChunkIndex: 'start-chunk-index',
+ startOffset: 'start-offset',
+ },
+} as const
+
+export const loadDocumentReadParams = createLoader(documentReadParams, documentReadUrlKeys)
+export const serializeDocumentReadParams = createSerializer(documentReadParams, documentReadUrlKeys)
diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx
index 41db4871567..9b9a6f78714 100644
--- a/apps/sim/app/o/[organizationId]/search/search.test.tsx
+++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx
@@ -90,7 +90,12 @@ beforeEach(() => {
chunkIndex: 0,
similarity: 1,
}
- return { data: [result], isPending: false, isFetching: false, isError: false }
+ return {
+ data: { query, results: [result], retrieval: { status: 'complete', timedOutLegs: [] } },
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ }
})
container = document.createElement('div')
document.body.appendChild(container)
diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
index f5adfa63f19..94221fc31fc 100644
--- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx
@@ -51,11 +51,6 @@ const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO))
-const SessionPolicySettings = dynamic(() =>
- import('@/ee/session-policy/components/session-policy-settings').then(
- (m) => m.SessionPolicySettings
- )
-)
const DataRetentionSettings = dynamic(() =>
import('@/ee/data-retention/components/data-retention-settings').then(
(m) => m.DataRetentionSettings
@@ -64,6 +59,9 @@ const DataRetentionSettings = dynamic(() =>
const DataDrainsSettings = dynamic(() =>
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
)
+const OrganizationSecuritySettings = dynamic(() =>
+ import('@/components/settings/organization-security').then((m) => m.OrganizationSecuritySettings)
+)
const UsageMonitoring = dynamic(() =>
import('@/ee/organization-usage/components/usage-monitoring').then((m) => m.UsageMonitoring)
)
@@ -113,7 +111,7 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
/>
)}
{section === 'sso' && }
- {section === 'sessions' && }
+ {section === 'security' && }
{section === 'data-retention' && }
{section === 'data-drains' && }
{section === 'whitelabeling' && }
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx
index bb3eab8e511..416f7f7746e 100644
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx
@@ -43,7 +43,7 @@ vi.mock('@/hooks/queries/search-integrations', () => ({
vi.mock('@/hooks/use-permission-config', () => ({
usePermissionConfig: mocks.availability,
}))
-vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({
+vi.mock('@/app/o/[organizationId]/settings/components/integrations/search-source-setup', () => ({
SearchSourceSetup: () => null,
}))
vi.mock('@/app/o/[organizationId]/settings/components/integrations/slack-account-setup', () => ({
diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx
index 359388941bd..4215fd58bab 100644
--- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx
@@ -6,16 +6,13 @@ import { useQueryStates } from 'nuqs'
import { SettingsPanel } from '@/components/settings/settings-panel'
import { organizationRoutes } from '@/lib/navigation/paths'
import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors'
+import { searchSetupAccessParam, searchSetupParam } from '@/lib/sim-search/search-params'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { AddOrganizationSourceModal } from '@/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal'
import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status'
+import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup'
import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
-import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
-import {
- searchSetupAccessParam,
- searchSetupParam,
-} from '@/app/workspace/[workspaceId]/search/search-params'
import {
SettingsEmptyState,
SettingsQueryErrorState,
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-pagination.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-pagination.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-pagination.tsx
rename to apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-pagination.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx
similarity index 79%
rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx
rename to apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx
index 3266b86d157..267977e0ead 100644
--- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx
@@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => ({
userId: 'user-1',
urlUpdate: vi.fn(),
oauthReturn: vi.fn(),
- sourceStatus: vi.fn(),
features: { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true },
create: vi.fn(),
update: vi.fn(),
@@ -105,16 +104,10 @@ vi.mock('@/hooks/use-permission-config', () => ({
refetchIntegrationAvailability: mocks.refetchAvailability,
}),
}))
-vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-status', () => ({
- SearchSourceStatus: (props: { knowledgeBaseId: string; connectorType: string }) => {
- mocks.sourceStatus(props)
- return Source sync status
- },
-}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mocks.replace, push: mocks.push }),
useParams: () => ({ workspaceId: 'workspace-1' }),
- usePathname: () => '/workspace/workspace-1/search',
+ usePathname: () => '/o/org-1/settings/integrations',
}))
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
useWorkspaceHostContext: () => ({ ownerBilling: {}, features: mocks.features }),
@@ -209,10 +202,10 @@ vi.mock('@/hooks/use-credential-refresh-triggers', () => ({
import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors'
import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors'
+import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup'
import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
import { AddConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal'
-import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
import { useConnectorSetupStore } from '@/stores/connector-setup/store'
let root: Root | null = null
@@ -382,17 +375,6 @@ afterEach(async () => {
vi.restoreAllMocks()
})
-function setup() {
- return (
-
- )
-}
-
function organizationSetup() {
return (
{
+ it.each([false, true])('does not load setup while closed (admin=%s)', async (canAdmin) => {
+ mocks.canAdmin = canAdmin
+ await render(organizationSetup())
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.basesQuery).toHaveBeenLastCalledWith('org-1', { enabled: false })
+ expect(mocks.prepare).not.toHaveBeenCalled()
+ })
+
+ it.each(['?addConnector=gitlab', '?manage-source=source-one'])(
+ 'blocks reader setup at %s',
+ async (query) => {
+ mocks.canAdmin = false
+ await render(organizationSetup(), query)
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.basesQuery).toHaveBeenLastCalledWith('org-1', { enabled: false })
+ expect(mocks.prepare).not.toHaveBeenCalled()
+ expect(mocks.replace).not.toHaveBeenCalled()
+ }
+ )
+
it.each([
{ type: 'google_drive', mode: 'admin', name: 'Connect Google Drive service account' },
{ type: 'gmail', mode: 'admin', name: 'Connect Gmail service account' },
@@ -477,15 +479,6 @@ describe('organization setup entry points', () => {
}
)
- it('keeps the broad provider picker without automatically preparing an index', async () => {
- mocks.bases = []
- await render(setup(), '?addConnector=')
- expect(document.body.textContent).toContain('Find a source')
- expect(document.body.textContent).toContain('Google Drive')
- expect(document.body.textContent).toContain('Confluence')
- expect(mocks.prepare).not.toHaveBeenCalled()
- })
-
it('retries failed preparation only when requested', async () => {
mocks.bases = []
await render(organizationSetup(), '?addConnector=google_drive')
@@ -568,7 +561,7 @@ describe('organization setup entry points', () => {
await render(organizationSetup(), `?addConnector=${type}`)
expect(mocks.replace).toHaveBeenCalledWith('/o/org-1/integrations')
expect(mocks.basesQuery).toHaveBeenLastCalledWith('org-1', { enabled: false })
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
+ expect(mocks.connectorsQuery).not.toHaveBeenCalled()
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(mocks.prepare).not.toHaveBeenCalled()
}
@@ -760,181 +753,11 @@ describe('Search source setup with real connector dialogs', () => {
)
if (source === 'source-one') expect(mocks.replace).toHaveBeenCalledWith(destination)
else expect(mocks.replace).not.toHaveBeenCalled()
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
- expect(document.querySelector('[role="dialog"]')).toBeNull()
- }
- )
-
- it.each([false, true])(
- 'does not fetch admin data while closed for canAdmin=%s',
- async (canAdmin) => {
- mocks.canAdmin = canAdmin
- await render(setup())
- expect(document.querySelector('[role="dialog"]')).toBeNull()
- expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
- }
- )
-
- it.each(['?addConnector=gitlab', '?manage-source=site-one', '?manage-source=confluence'])(
- 'does not expose the catalog or admin queries to a reader opening %s',
- async (searchParams) => {
- mocks.canAdmin = false
- await render(setup(), searchParams)
+ expect(mocks.connectorsQuery).not.toHaveBeenCalled()
expect(document.querySelector('[role="dialog"]')).toBeNull()
- expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
- expect(mocks.prepare).not.toHaveBeenCalled()
- }
- )
-
- it('closes source management and disables admin queries when the viewer loses admin access', async () => {
- mocks.connectors = [
- { id: 'site-one', connectorType: 'confluence', accessMode: 'admin', status: 'active' },
- ]
- await render(setup(), '?manage-source=site-one')
- expect(document.querySelector('[role="dialog"]')).not.toBeNull()
- mocks.canAdmin = false
- mocks.sourceStatus.mockClear()
- await render(setup(), '?manage-source=site-one')
- expect(document.querySelector('[role="dialog"]')).toBeNull()
- expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false })
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
- expect(mocks.sourceStatus).not.toHaveBeenCalled()
- })
-
- it('lists each eligible provider once and filters the add-source catalog', async () => {
- await render(setup(), '?addConnector=')
- expect(
- Array.from(document.querySelectorAll('button')).filter(
- (node) => node.textContent === 'Set up'
- )
- ).toHaveLength(8)
- for (const name of [
- 'Confluence',
- 'GitHub',
- 'GitLab',
- 'Gmail',
- 'Google Calendar',
- 'Google Drive',
- 'Jira',
- 'Slack',
- ]) {
- expect(document.body.textContent).toContain(name)
- }
- await fill('Find a source…', 'no-such-source')
- expect(document.body.textContent).toContain('No matching sources.')
- expect(document.body.textContent).not.toContain('Google Drive')
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
- })
-
- it('waits for availability and offers a retry after it fails', async () => {
- mocks.availabilityReady = false
- mocks.availabilityLoading = true
- await render(setup(), '?addConnector=')
- expect(document.body.textContent).toContain('Loading sources…')
- expect(
- Array.from(document.querySelectorAll('button')).some((node) => node.textContent === 'Set up')
- ).toBe(false)
- mocks.availabilityLoading = false
- mocks.availabilityError = new Error('Availability failed')
- await render(setup(), '?addConnector=')
- expect(document.body.textContent).toContain('Availability failed')
- await click(button('Try again'))
- expect(mocks.refetchAvailability).toHaveBeenCalledOnce()
- })
-
- it('does not offer GitHub App setup when only its workflow token integration is available', async () => {
- mocks.unavailableProviders = ['github-repositories']
- await render(setup(), '?addConnector=')
- expect(
- Array.from(document.querySelectorAll('button')).filter(
- (node) => node.textContent === 'Set up'
- )
- ).toHaveLength(7)
- expect(document.body.textContent).toContain('GitHub')
- expect(document.body.textContent).toContain('Not available in this workspace')
- })
-
- it('preserves a setup draft while an availability refresh fails and recovers', async () => {
- await render(setup(), '?addConnector=github')
- await fill('owner/repo', 'acme/docs')
- expect(button('Create & Invite').disabled).toBe(false)
- mocks.availabilityReady = false
- mocks.availabilityError = new Error('Availability refresh failed')
- await render(setup(), '?addConnector=github')
- expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe(
- 'acme/docs'
- )
- expect(button('Create & Invite').disabled).toBe(true)
- await click(button('Try again'))
- expect(mocks.refetchAvailability).toHaveBeenCalledOnce()
- mocks.availabilityReady = true
- mocks.availabilityError = null
- await render(setup(), '?addConnector=github')
- expect(button('Create & Invite').disabled).toBe(false)
- })
-
- it.each(['gmail', 'jira', 'github', 'google_calendar'])(
- 'retains %s member setup without workspace-wide access',
- async (type) => {
- await render(setup(), `?addConnector=${type}`)
- if (type === 'gmail' || type === 'google_calendar') {
- await click(button('Member accounts'))
- }
- expect(document.body.textContent).toContain('Member accounts')
- expect(
- Array.from(document.querySelectorAll('button')).some(
- (node) => node.textContent === 'Workspace'
- )
- ).toBe(false)
- expect(document.body.textContent).not.toContain('Sync Frequency')
- expect(document.body.textContent).not.toContain('Max Threads')
- expect(document.body.textContent).not.toContain('Max Events')
- expect(document.body.textContent).not.toContain('Max Files')
- expect(document.body.textContent).not.toContain('Max Issues')
- if (type === 'github') await fill('owner/repo', 'acme/docs')
- if (type === 'jira') {
- expect(button('Create & Invite').disabled).toBe(true)
- await fill('yoursite.atlassian.net', 'acme.atlassian.net')
- const modeToggle = document.querySelector(
- 'button[aria-label="Switch Projects to manual input"]'
- )
- expect(modeToggle).not.toBeNull()
- await click(modeToggle!)
- await fill('e.g. ENG, PROJ (comma-separated for multiple)', 'ENG')
- }
- if (type === 'gmail') {
- expect(document.body.textContent).not.toContain('Browse with')
- expect(
- document.querySelector('input[placeholder="e.g. INBOX, Engineering (comma-separated)"]')
- ).not.toBeNull()
- expect(document.querySelector('button[aria-label="Switch Labels to selector"]')).toBeNull()
- }
- expect(button('Create & Invite').disabled).toBe(false)
- await click(button('Create & Invite'))
- expect(mocks.create).toHaveBeenCalledWith(
- expect.objectContaining({
- knowledgeBaseId: 'kb-search',
- connectorType: type,
- accessMode: 'members',
- }),
- expect.any(Object)
- )
}
)
- it('prepares a canonical index instead of an ordinary base with the Search name', async () => {
- mocks.bases = [{ id: 'ordinary-base', name: 'Sim Search', isSearchIndex: false }]
- await render(setup(), '?addConnector=gitlab')
- expect(mocks.prepare).toHaveBeenCalledWith({
- workspaceId: 'workspace-1',
- connectorType: 'gitlab',
- accessMode: 'admin',
- })
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined)
- })
-
it('prepares organization connected-account indexing in members mode even when central access is available', async () => {
mocks.bases = []
await render(
@@ -952,169 +775,6 @@ describe('Search source setup with real connector dialogs', () => {
accessMode: 'members',
})
})
-
- it('does not reuse mutation data after the current index has been removed', async () => {
- mocks.prepareData = { knowledgeBaseId: 'kb-search' }
- mocks.bases = []
- await render(setup(), '?addConnector=gitlab')
- expect(mocks.prepare).toHaveBeenCalled()
- expect(document.querySelector('input[placeholder="Enter your GitLab PAT"]')).toBeNull()
- })
-
- it.each(['bases', 'connectors'] as const)(
- 'retries a failed %s discovery query',
- async (query) => {
- if (query === 'bases') mocks.basesError = new Error('Base discovery failed')
- else mocks.connectorsError = new Error('Connector discovery failed')
- await render(setup(), query === 'bases' ? '?addConnector=' : '?manage-source=gitlab-1')
- expect(document.body.textContent).toContain('discovery failed')
- await click(button('Try again'))
- expect(
- query === 'bases' ? mocks.refetchBases : mocks.refetchConnectors
- ).toHaveBeenCalledOnce()
- }
- )
-
- it('manages the exact source ID in a renamed canonical index', async () => {
- mocks.bases = [
- { id: 'ordinary-base', name: 'Sim Search', isSearchIndex: false },
- { id: 'renamed-index', name: 'Company knowledge', isSearchIndex: true },
- ]
- mocks.connectors = [
- { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' },
- { id: 'site-two', connectorType: 'confluence', accessMode: 'admin', status: 'active' },
- ]
- await render(setup(), '?manage-source=site-two')
- expect(mocks.connectorsQuery).toHaveBeenLastCalledWith('renamed-index')
- expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
- expect.objectContaining({
- knowledgeBaseId: 'renamed-index',
- connectorType: 'confluence',
- connectors: [mocks.connectors[1]],
- })
- )
- expect(mocks.prepare).not.toHaveBeenCalled()
- })
-
- it.each(['unknown-source', 'deleted-source'])(
- 'shows unavailable for a missing connector ID: %s',
- async (id) => {
- mocks.connectors = [
- {
- id: 'existing-source',
- connectorType: 'confluence',
- accessMode: 'members',
- status: 'active',
- },
- ]
- await render(setup(), `?manage-source=${id}`)
- expect(document.body.textContent).toContain('This source is no longer available.')
- expect(document.body.textContent).not.toContain('Source sync status')
- expect(mocks.sourceStatus).not.toHaveBeenCalled()
- }
- )
-
- it('waits for connector discovery before declaring a management link unavailable', async () => {
- mocks.connectorsPending = true
- await render(setup(), '?manage-source=site-one')
- expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
- expect.objectContaining({ isLoading: true, connectors: [] })
- )
- expect(document.body.textContent).not.toContain('This source is no longer available.')
- mocks.connectorsPending = false
- mocks.connectors = [
- { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' },
- ]
- await render(setup(), '?manage-source=site-one')
- expect(document.body.textContent).toContain('Source sync status')
- expect(document.body.textContent).not.toContain('This source is no longer available.')
- expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
- expect.objectContaining({ connectorType: 'confluence', connectors: mocks.connectors })
- )
- })
-
- it('keeps a failed connector lookup retryable instead of treating it as deletion', async () => {
- mocks.connectorsError = new Error('Source lookup failed')
- await render(setup(), '?manage-source=site-one')
- expect(document.body.textContent).toContain('Source lookup failed')
- expect(document.body.textContent).not.toContain('This source is no longer available.')
- expect(mocks.sourceStatus).not.toHaveBeenCalled()
- await click(button('Try again'))
- expect(mocks.refetchConnectors).toHaveBeenCalledOnce()
- mocks.connectorsError = null
- await render(setup(), '?manage-source=site-one')
- expect(document.body.textContent).toContain('This source is no longer available.')
- })
-
- it('preserves existing provider-based management URLs', async () => {
- mocks.connectors = [
- { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' },
- { id: 'site-two', connectorType: 'confluence', accessMode: 'admin', status: 'active' },
- ]
- await render(setup(), '?manage-source=confluence')
- expect(mocks.sourceStatus).toHaveBeenLastCalledWith(
- expect.objectContaining({ connectors: mocks.connectors })
- )
- })
-
- it('opens GitLab token tabs and submits the custom host and PAT with managed access', async () => {
- await render(setup(), '?addConnector=gitlab')
- expect(button('Administrator token')).toHaveAttribute('aria-checked', 'true')
- expect(button('Non-admin token')).toHaveAttribute('aria-checked', 'false')
- expect(document.body.textContent).not.toContain('Connection method')
- expect(document.body.textContent).not.toContain('Member accounts')
- expect(button('Connect & Sync')).toBeDisabled()
- await fill('Enter your GitLab PAT', 'test-pat')
- await fill('group/project or numeric ID', 'engineering/search')
- expect(button('Connect & Sync')).toBeDisabled()
- await fill('gitlab.example.com', 'gitlab.example.test')
- expect(button('Connect & Sync')).toBeEnabled()
- await click(button('Connect & Sync'))
- expect(mocks.create).toHaveBeenCalledWith(
- expect.objectContaining({
- knowledgeBaseId: 'kb-search',
- connectorType: 'gitlab',
- accessMode: 'admin',
- apiKey: 'test-pat',
- sourceConfig: expect.objectContaining({
- host: 'gitlab.example.test',
- project: 'engineering/search',
- }),
- syncIntervalMinutes: 60,
- }),
- expect.any(Object)
- )
- expect(mocks.create.mock.calls[0][0]).not.toHaveProperty('credentialId')
- })
-
- it('prepares Slack in members mode when mirrored access is disabled', async () => {
- mocks.features.knowledgeSourceMirroredAccess = false
- mocks.bases = []
- await render(setup(), '?addConnector=slack')
- expect(mocks.prepare).toHaveBeenCalledWith({
- workspaceId: 'workspace-1',
- connectorType: 'slack',
- accessMode: 'members',
- })
- })
-
- it('blocks unavailable catalog providers and duplicate preparation while preserving retry feedback', async () => {
- mocks.features.knowledgeMemberAccess = false
- mocks.features.knowledgeSourceMirroredAccess = false
- await render(setup(), '?addConnector=')
- expect(document.body.textContent).toContain('Not available in this workspace')
- expect(
- Array.from(document.querySelectorAll('button')).some((node) => node.textContent === 'Set up')
- ).toBe(false)
- mocks.features.knowledgeSourceMirroredAccess = true
- mocks.bases = []
- mocks.preparePending = true
- mocks.prepareError = new Error('Source preparation failed')
- await render(setup(), '?addConnector=')
- await fill('Find a source…', 'gitlab')
- expect(button('Set up')).toBeDisabled()
- expect(document.body.textContent).toContain('Source preparation failed')
- })
})
describe('member content credentials in real add and edit dialogs', () => {
@@ -2061,27 +1721,6 @@ describe('canonical Search connector safety', () => {
describe('resuming Search source setup', () => {
const key = 'user-1:workspace-1:kb-search:slack:choose'
- it('reopens the source from the URL even when the source filter hides its row', async () => {
- await render(setup(), '?search=nothing-matches&addConnector=gitlab&credentialDraftId=draft-1')
- expect(button('Administrator token')).toHaveAttribute('aria-checked', 'true')
- expect(button('Non-admin token')).toHaveAttribute('aria-checked', 'false')
- expect(document.body.textContent).not.toContain('Connection method')
- expect(document.body.textContent).toContain('Add GitLab project')
- expect(document.body.textContent).not.toContain('Sync Frequency')
- expect(document.body.textContent).not.toContain('Sync automatically')
- expect(mocks.prepare).not.toHaveBeenCalled()
- })
-
- it('keeps the picker open when changing sources and updates the configuration selection', async () => {
- await render(setup(), '?addConnector=google_drive')
- await click(button('Choose another source'))
- expect(document.querySelector('[role="dialog"]')).not.toBeNull()
- expect(document.body.textContent).toContain('Add source')
- await fill('Find a source…', 'confluence')
- await click(button('Set up'))
- expect(document.body.textContent).toContain('Connect Confluence site')
- })
-
it('restores the source configuration and content account after an account-settings detour', async () => {
const onCreated = vi.fn()
const form = (
@@ -2105,7 +1744,7 @@ describe('resuming Search source setup', () => {
const setup = Array.from(document.querySelectorAll('a')).find(
(link) => link.textContent === 'Set up Slack'
)
- expect(setup?.getAttribute('href')).toContain('search-setup=slack')
+ expect(setup?.getAttribute('href')).toBe('/workspace/workspace-1/settings/credential-groups')
setup?.addEventListener('click', (event) => event.preventDefault())
await click(setup!)
expect(useConnectorSetupStore.getState().getDraft(key)).toMatchObject({
@@ -2220,49 +1859,6 @@ describe('resuming Search source setup', () => {
})
describe('Search setup guides', () => {
- it('opens the source guide in a new tab without losing an administrator’s setup', async () => {
- const open = vi.spyOn(window, 'open').mockReturnValue(null)
- await render(setup(), '?addConnector=github')
- await fill('owner/repo', 'acme/docs')
-
- await click(button('Setup guide'))
-
- expect(open).toHaveBeenCalledWith(
- 'https://docs.sim.ai/search/github',
- '_blank',
- 'noopener,noreferrer'
- )
- expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe(
- 'acme/docs'
- )
- expect(mocks.create).not.toHaveBeenCalled()
- expect(mocks.urlUpdate).not.toHaveBeenCalled()
- await click(button('Create & Invite'))
- expect(mocks.create).toHaveBeenCalledWith(
- expect.objectContaining({
- connectorType: 'github',
- sourceConfig: expect.objectContaining({ repository: 'acme/docs' }),
- }),
- expect.any(Object)
- )
- })
-
- it('offers the Slack guide before its custom app is configured', async () => {
- const open = vi.spyOn(window, 'open').mockReturnValue(null)
- mocks.credentialGroup = null
- await render(setup(), '?addConnector=slack')
-
- await click(button('Setup guide'))
-
- expect(open).toHaveBeenCalledWith(
- 'https://docs.sim.ai/search/slack',
- '_blank',
- 'noopener,noreferrer'
- )
- expect(document.body.textContent).not.toContain('Create & Invite')
- expect(mocks.create).not.toHaveBeenCalled()
- })
-
it('preserves a member’s required source fields while reading the guide', async () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const onClose = vi.fn()
diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx
similarity index 80%
rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx
rename to apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx
index 81f1c9fb562..1f2817c553d 100644
--- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx
@@ -18,18 +18,17 @@ import { useSession } from '@/lib/auth/auth-client'
import {
type ResourceScope,
resourceScopeFields,
- resourceScopeFromOwner,
resourceScopeKey,
} from '@/lib/core/resource-scope'
import { organizationRoutes } from '@/lib/navigation/paths'
import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels'
import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors'
-import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import {
managedSourceParam,
searchSetupAccessParam,
searchSetupParam,
-} from '@/app/workspace/[workspaceId]/search/search-params'
+} from '@/lib/sim-search/search-params'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import {
SettingsEmptyState,
SettingsQueryErrorState,
@@ -39,11 +38,7 @@ import {
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
-import {
- useConnectorList,
- usePrepareSearchSource,
- useSearchIndex,
-} from '@/hooks/queries/kb/connectors'
+import { usePrepareSearchSource, useSearchIndex } from '@/hooks/queries/kb/connectors'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const AddConnectorModal = dynamic(
@@ -53,17 +48,8 @@ const AddConnectorModal = dynamic(
),
{ ssr: false }
)
-const SearchSourceStatus = dynamic(
- () =>
- import('@/app/workspace/[workspaceId]/search/components/search-source-status').then(
- (module) => module.SearchSourceStatus
- ),
- { ssr: false }
-)
-
interface SearchSourceSetupProps {
- workspaceId?: string
- scope?: ResourceScope
+ scope: Extract
canAdmin: boolean
memberAccessAvailable: boolean
mirroredAccessAvailable: boolean
@@ -71,13 +57,11 @@ interface SearchSourceSetupProps {
/** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */
export function SearchSourceSetup({
- workspaceId,
- scope: explicitScope,
+ scope,
canAdmin,
memberAccessAvailable,
mirroredAccessAvailable,
}: SearchSourceSetupProps) {
- const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId })
const { data: session } = useSession()
const {
integrationAvailability,
@@ -109,16 +93,14 @@ export function SearchSourceSetup({
const { mutate: prepareSource, isPending: preparing } = prepare
const selectedMeta = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : undefined
const redirectPersonalSetup = Boolean(
- scope.kind === 'organization' &&
- canAdmin &&
+ canAdmin &&
selectedMeta &&
setup['source-access'] !== 'members' &&
!selectedMeta.mirrorsSourceAcls &&
selectedType !== 'slack'
)
- const redirectManagement =
- scope.kind === 'organization' && canAdmin && managedSource !== null && selectedType === null
- const organizationId = scope.kind === 'organization' ? scope.organizationId : undefined
+ const redirectManagement = canAdmin && managedSource !== null && selectedType === null
+ const { organizationId } = scope
useEffect(() => {
if (redirectPersonalSetup && organizationId) {
router.replace(organizationRoutes(organizationId).integrations)
@@ -138,46 +120,17 @@ export function SearchSourceSetup({
enabled: canAdmin && open && !redirectManagement && !redirectPersonalSetup,
})
const knowledgeBaseId = index.data?.knowledgeBaseId ?? undefined
- const connectors = useConnectorList(
- canAdmin && managedSource && !redirectManagement ? knowledgeBaseId : undefined
- )
const close = () => {
if (prepare.isPending) return
if (selectedType !== null) void setSelectedType(null)
if (managedSource !== null) void setManagedSource(null)
}
- const failedQuery = index.isError
- ? index
- : managedSource && connectors.isError
- ? connectors
- : null
- const managedConnectors =
- connectors.data?.filter(
- (connector) => connector.id === managedSource || connector.connectorType === managedSource
- ) ?? []
- const managedType =
- managedConnectors[0]?.connectorType ??
- (managedSource && CONNECTOR_META_REGISTRY[managedSource] ? managedSource : undefined)
- const initialMode = (type: string) => {
- if (scope.kind === 'organization')
- return setup['source-access'] === 'members' || type === 'slack'
- ? ('members' as const)
- : ('admin' as const)
- const meta = CONNECTOR_META_REGISTRY[type]
- if (
- meta &&
- getConnectorAccessAvailability(meta, integrationAvailability, {
- memberAccessAvailable,
- mirroredAccessAvailable,
- oauthServiceAvailability,
- isIntegrationAvailabilityReady:
- isIntegrationAvailabilityReady || integrationAvailability.size > 0,
- }).admin
- )
- return 'admin' as const
- return 'members' as const
- }
+ const failedQuery = index.isError ? index : null
+ const initialMode = (type: string) =>
+ setup['source-access'] === 'members' || type === 'slack'
+ ? ('members' as const)
+ : ('admin' as const)
const selectedAccessMode = selectedType ? initialMode(selectedType) : undefined
const selectedAvailability = selectedMeta
@@ -189,11 +142,9 @@ export function SearchSourceSetup({
})
: undefined
const selectedAvailable = selectedAvailability
- ? scope.kind === 'organization'
- ? selectedAccessMode === 'admin'
- ? selectedAvailability.admin
- : selectedAvailability.members
- : selectedAvailability.admin || selectedAvailability.members
+ ? selectedAccessMode === 'admin'
+ ? selectedAvailability.admin
+ : selectedAvailability.members
: false
const canPrepareSelected =
canAdmin &&
@@ -241,11 +192,9 @@ export function SearchSourceSetup({
) {
if (selectedType && session?.user?.id) {
const accessMode = initialMode(selectedType)
- const setupMode =
- scope.kind === 'organization' &&
- !(selectedMeta?.mirrorsSourceAcls && selectedMeta.auth.mode === 'oauth')
- ? accessMode
- : 'choose'
+ const setupMode = !(selectedMeta?.mirrorsSourceAcls && selectedMeta.auth.mode === 'oauth')
+ ? accessMode
+ : 'choose'
return (
void setSelectedType(type !== null ? searchSetupParam.parser.parse(type) : null)
}
onCreated={async (_type, connector) => {
- if (scope.kind !== 'organization') return
await setSelectedType(null)
router.push(organizationRoutes(scope.organizationId).searchSource(connector.id))
}}
/>
)
}
- if (managedSource && (connectors.isPending || managedType)) {
- return (
- void setManagedSource(null)}
- />
- )
- }
}
if (
@@ -299,10 +235,7 @@ export function SearchSourceSetup({
const normalizedSearch = search.trim().toLowerCase()
const visibleTypes = SEARCH_SOURCE_TYPES.filter(
([type, meta]) =>
- (scope.kind !== 'organization' ||
- setup['source-access'] === 'members' ||
- meta.mirrorsSourceAcls ||
- type === 'slack') &&
+ (setup['source-access'] === 'members' || meta.mirrorsSourceAcls || type === 'slack') &&
`${meta.name} ${meta.description}`.toLowerCase().includes(normalizedSearch)
)
@@ -400,11 +333,7 @@ export function SearchSourceSetup({
}
)
const available =
- scope.kind === 'organization'
- ? setup['source-access'] === 'members' || type === 'slack'
- ? members
- : central
- : central || members
+ setup['source-access'] === 'members' || type === 'slack' ? members : central
return (
(
return Provider account connections
},
}))
-vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({
+vi.mock('@/app/o/[organizationId]/settings/components/integrations/search-source-setup', () => ({
SearchSourceSetup: (props: unknown) => {
mocks.setup(props)
return null
diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx
index ecf0c0f62b5..5e98e688040 100644
--- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx
@@ -11,18 +11,15 @@ import { SettingsPanel } from '@/components/settings/settings-panel'
import { organizationRoutes } from '@/lib/navigation/paths'
import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels'
import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors'
+import { searchSetupAccessParam, searchSetupParam } from '@/lib/sim-search/search-params'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status'
import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params'
+import { SearchSourcePagination } from '@/app/o/[organizationId]/settings/components/integrations/search-source-pagination'
+import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup'
import { OrganizationSlackAccountRemoval } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-removal'
import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
-import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination'
-import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
-import {
- searchSetupAccessParam,
- searchSetupParam,
-} from '@/app/workspace/[workspaceId]/search/search-params'
import {
SettingsEmptyState,
SettingsQueryErrorState,
diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts
index 36c23895f8c..94ec37b962a 100644
--- a/apps/sim/app/o/[organizationId]/settings/navigation.test.ts
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.test.ts
@@ -73,6 +73,8 @@ describe('organization settings navigation', () => {
)
expect(resolveOrganizationSettingsSection('subscription')).toBe('billing')
expect(resolveOrganizationSettingsSection('domains')).toBe('sso')
+ expect(resolveOrganizationSettingsSection('sessions')).toBe('security')
+ expect(resolveOrganizationSettingsSection('/o/one/settings/network')).toBeNull()
expect(resolveOrganizationSettingsSection('skills')).toBeNull()
expect(buildOrganizationNavItems('org', true).map(({ id }) => id)).toEqual([
'home',
@@ -92,7 +94,7 @@ describe('organization settings navigation', () => {
'governance:audit-logs',
'governance:access-control',
'governance:sso',
- 'governance:sessions',
+ 'governance:security',
'governance:data-retention',
'governance:data-drains',
'sim-search:integrations',
diff --git a/apps/sim/app/o/[organizationId]/settings/navigation.ts b/apps/sim/app/o/[organizationId]/settings/navigation.ts
index 124ff827666..0a96e8a0754 100644
--- a/apps/sim/app/o/[organizationId]/settings/navigation.ts
+++ b/apps/sim/app/o/[organizationId]/settings/navigation.ts
@@ -37,7 +37,13 @@ export function resolveOrganizationSettingsSection(
path,
items: ORGANIZATION_SETTINGS_ITEMS,
defaultSection: null,
- aliases: { organization: 'members', team: 'members', subscription: 'billing', domains: 'sso' },
+ aliases: {
+ organization: 'members',
+ team: 'members',
+ subscription: 'billing',
+ domains: 'sso',
+ sessions: 'security',
+ },
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
index b94535be58c..c6050bf0f43 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
@@ -347,6 +347,8 @@ describe('ConnectOAuthModal reauthorization', () => {
},
})
+ expect(container.querySelector('header')).toHaveTextContent('Reconnect Slack')
+ expect(container).not.toHaveTextContent('tool requires access')
await clickConnect()
expect(mocks.writeOAuthReturnContext).toHaveBeenCalledExactlyOnceWith(
@@ -424,6 +426,8 @@ describe('ConnectOAuthModal reauthorization', () => {
},
})
+ expect(container.querySelector('header')).toHaveTextContent('Connect Slack')
+ expect(container).toHaveTextContent('tool requires access')
await clickConnect()
expect(mocks.writeOAuthReturnContext).toHaveBeenCalledOnce()
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index 4ec62347415..e624f434d0e 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -506,7 +506,16 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
? `An integration named "${existingCredential.displayName}" already exists.`
: undefined)
- const title = `Connect ${providerName}`
+ const isConnectorReconnect = !isConnect && props.returnContext?.origin === 'kb-connectors'
+ const connectLabel = isConnectorReconnect
+ ? newScopes.length > 0
+ ? 'Update access'
+ : 'Reconnect'
+ : 'Connect'
+ const title =
+ isConnectorReconnect && newScopes.length > 0
+ ? `Update ${providerName} access`
+ : `${connectLabel} ${providerName}`
return (
@@ -519,7 +528,11 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
{!isConnect && (
- The "{props.toolName}" tool requires access to your account.
+ {isConnectorReconnect
+ ? newScopes.length > 0
+ ? 'Approve the requested permissions to continue syncing.'
+ : `Continue to ${providerName} to restore this connection.`
+ : `The "${props.toolName}" tool requires access to your account.`}
)}
@@ -651,7 +664,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
: undefined
}
primaryAction={{
- label: isPending ? 'Connecting...' : 'Connect',
+ label: isPending ? 'Connecting...' : connectLabel,
onClick: handleConnect,
disabled: isDisabled,
}}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx
index 226c60c89b1..15fd822f500 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx
@@ -3,22 +3,13 @@
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockMemberAccessAvailable } = vi.hoisted(() => ({
- mockMemberAccessAvailable: vi.fn(() => true),
-}))
-
-vi.mock('@/hooks/use-member-access', () => ({
- useMemberAccessAvailable: () => mockMemberAccessAvailable(),
-}))
-
+import { afterEach, describe, expect, it } from 'vitest'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header'
let root: Root | null = null
let container: HTMLDivElement | null = null
-function mount(active: 'integrations' | 'skills' | 'search' = 'integrations') {
+function mount(active: 'integrations' | 'skills' = 'integrations') {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
@@ -30,10 +21,6 @@ function tabs(): string[] {
return Array.from(container?.querySelectorAll('a') ?? []).map((node) => node.textContent ?? '')
}
-beforeEach(() => {
- mockMemberAccessAvailable.mockReturnValue(true)
-})
-
afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
@@ -45,20 +32,14 @@ describe('IntegrationTabsHeader', () => {
it('links every tab to its page in the routed workspace', () => {
mount()
- expect(tabs()).toEqual(['Integrations', 'Skills', 'Search'])
+ expect(tabs()).toEqual(['Integrations', 'Skills'])
expect(
Array.from(container?.querySelectorAll('a') ?? []).map((node) => node.getAttribute('href'))
- ).toEqual([
- '/workspace/workspace-1/integrations',
- '/workspace/workspace-1/skills',
- '/workspace/workspace-1/search',
- ])
+ ).toEqual(['/workspace/workspace-1/integrations', '/workspace/workspace-1/skills'])
})
- it('omits Search where per-member access is off, matching the page that 404s', () => {
- mockMemberAccessAvailable.mockReturnValue(false)
- mount()
-
+ it('keeps the same workspace navigation on the Skills tab', () => {
+ mount('skills')
expect(tabs()).toEqual(['Integrations', 'Skills'])
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
index 248d7936669..11774143550 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
@@ -3,38 +3,20 @@
import type { ReactNode } from 'react'
import { ChipLink, cn } from '@sim/emcn'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
-import { useMemberAccessAvailable } from '@/hooks/use-member-access'
interface IntegrationTabsHeaderProps {
- active: 'integrations' | 'skills' | 'search'
+ active: 'integrations' | 'skills'
workspaceId: string
/** Trailing actions for the owning page (e.g. skills' "Add skill"). */
rightSlot?: ReactNode
}
-/**
- * Top-of-page tab header shared by the Integrations, Skills, and Search pages —
- * three views of one surface, so each highlights itself and links to its siblings.
- *
- * Lives in the shared workspace components rather than under `integrations/`
- * because every page owns it equally; its former home made Skills reach across
- * into a sibling feature for its own chrome.
- *
- * Search appears only where per-member access is on, matching the page it links
- * to, which 404s otherwise. A client component so the three pages and their
- * Suspense fallbacks all read that one judgement from the workspace host
- * context rather than each resolving it again on the server.
- *
- * The `gap-1` is explicit because chips carry no outer margin — the parent owns the
- * space between them.
- */
+/** Shared navigation for workspace integrations and skills. */
export function IntegrationTabsHeader({
active,
workspaceId,
rightSlot,
}: IntegrationTabsHeaderProps) {
- const memberAccessAvailable = useMemberAccessAvailable()
-
return (
@@ -43,11 +25,6 @@ export function IntegrationTabsHeader({
Skills
- {memberAccessAvailable && (
-
- Search
-
- )}
{rightSlot && {rightSlot}
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
index f41648b121d..6b9617c5d38 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
@@ -4,30 +4,39 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({ overview: vi.fn() }))
+const mocks = vi.hoisted(() => ({
+ index: vi.fn(),
+ overview: vi.fn(),
+ search: vi.fn(),
+ retry: vi.fn(),
+}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
- useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }),
+ useSearchIndex: mocks.index,
useSearchSourceOverview: mocks.overview,
}))
vi.mock('@/hooks/queries/kb/knowledge', () => ({
- useWorkspaceKnowledgeSearch: () => ({
- data: [],
- isPending: false,
- isFetching: false,
- isError: false,
- }),
+ useWorkspaceKnowledgeSearch: mocks.search,
}))
vi.mock(
'@/app/workspace/[workspaceId]/home/components/message-content/components/source-card',
- () => ({ SourceCard: () => null })
+ () => ({ SourceCard: ({ source }: { source: { title: string } }) => {source.title} })
)
+import type { ResourceScope } from '@/lib/core/resource-scope'
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
+ mocks.index.mockReturnValue({ data: { knowledgeBaseId: 'index' }, isPending: false })
+ mocks.search.mockReturnValue({
+ data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } },
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ refetch: mocks.retry,
+ })
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
root = createRoot(container)
@@ -36,11 +45,11 @@ afterEach(() => {
act(() => root.unmount())
vi.unstubAllGlobals()
})
-async function render() {
+async function render(scope: ResourceScope = { kind: 'workspace', workspaceId: 'workspace' }) {
await act(async () =>
root.render(
-
+
)
)
@@ -66,6 +75,68 @@ describe('source indexing context in search results', () => {
mocks.overview.mockReturnValue({ data: undefined })
await render()
expect(container.textContent).not.toContain('Still indexing')
- expect(container.textContent).toContain('No documents you can read match')
+ expect(container.textContent).toContain('Search found no results.')
+ })
+})
+
+describe('incomplete search coverage', () => {
+ it.each([false, true])(
+ 'shows matches without timeout copy or retry controls (hasResults=%s)',
+ async (hasResults) => {
+ mocks.search.mockReturnValue({
+ data: {
+ query: 'launch',
+ results: hasResults
+ ? [
+ {
+ documentId: 'document-1',
+ knowledgeBaseId: 'index',
+ knowledgeBaseName: 'Search index',
+ documentName: 'Release plan',
+ sourceUrl: 'https://fixture.test/release',
+ connectorType: null,
+ sourceModifiedAt: null,
+ author: null,
+ content: 'launch details',
+ chunkIndex: 0,
+ similarity: 0.9,
+ },
+ ]
+ : [],
+ retrieval: { status: 'partial', timedOutLegs: ['vector'] },
+ },
+ isPending: false,
+ isFetching: false,
+ isError: false,
+ refetch: mocks.retry,
+ })
+ await render()
+ expect(container.textContent).not.toContain('Search couldn’t run')
+ expect(container.textContent).not.toContain('No documents')
+ expect(container.textContent).not.toContain('Some results may be missing.')
+ expect(container.textContent).not.toContain('Search is incomplete.')
+ expect(container.textContent).toContain(
+ hasResults ? '1 document' : 'Search found no results.'
+ )
+ if (hasResults) expect(container.textContent).toContain('Release plan')
+ const retry = [...container.querySelectorAll('button')].find(
+ (button) => button.textContent === 'Try again'
+ )
+ expect(retry).toBeUndefined()
+ expect(mocks.retry).not.toHaveBeenCalled()
+ }
+ )
+})
+
+describe('source setup navigation', () => {
+ it.each([
+ [{ kind: 'workspace', workspaceId: 'workspace' }, '/workspace/workspace/knowledge'],
+ [{ kind: 'organization', organizationId: 'organization' }, '/o/organization/integrations'],
+ ] as const)('links empty results to the source page for %j', async (scope, href) => {
+ mocks.index.mockReturnValue({ data: { knowledgeBaseId: null }, isPending: false })
+ mocks.overview.mockReturnValue({ data: undefined })
+ await render(scope)
+ expect(container.textContent).toContain('No sources are set up yet.')
+ expect(container.querySelector('a')?.getAttribute('href')).toBe(href)
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index 0c8395fd1cb..3c92726ee09 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -133,7 +133,7 @@ export function KnowledgeSearchResults({
}
}, [filters.source, filters.updated])
const {
- data: results,
+ data: search,
isPending,
isFetching,
isError: searchFailed,
@@ -143,7 +143,7 @@ export function KnowledgeSearchResults({
const indexing = (overview?.providers ?? [])
.filter((provider) => provider.isSyncing)
.map((provider) => connectorDisplayName(provider.connectorType))
- const documents = useMemo(() => groupResultsByDocument(results ?? []), [results])
+ const documents = useMemo(() => groupResultsByDocument(search?.results ?? []), [search?.results])
const sourceTypes = [
...new Set([
...(filters.source ? [filters.source] : []),
@@ -155,8 +155,7 @@ export function KnowledgeSearchResults({
const showFilters =
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
- /* A failed search says so in one quiet line and offers to run again; the cause is
- the server's to log, never the reader's to parse. */
+ /** A failed search offers a retry; server diagnostics carry the cause. */
if (basesFailed || searchFailed) {
const retrying = basesFetching || isFetching
return (
@@ -180,7 +179,7 @@ export function KnowledgeSearchResults({
href={
scope.kind === 'organization'
? `/o/${scope.organizationId}/integrations`
- : `/workspace/${scope.workspaceId}/search`
+ : `/workspace/${scope.workspaceId}/knowledge`
}
>
View sources
@@ -188,7 +187,7 @@ export function KnowledgeSearchResults({
)
}
- if (isPending || (isFetching && !results)) {
+ if (isPending || (isFetching && !search)) {
return (
@@ -205,10 +204,16 @@ export function KnowledgeSearchResults({
-
- {documents.length === 1 ? '1 document' : `${documents.length} documents`}
-
- {' · searched as you'}
+ {documents.length === 0 ? (
+ 'Search found no results.'
+ ) : (
+ <>
+
+ {documents.length === 1 ? '1 document' : `${documents.length} documents`}
+
+ {' · searched as you'}
+ >
+ )}
{indexingNote && {indexingNote} }
@@ -244,13 +249,7 @@ export function KnowledgeSearchResults({
))}
)}
- {documents.length === 0 ? (
-
- {filtersActive
- ? 'No documents match these filters.'
- : `No documents you can read match “${query}”.`}
-
- ) : (
+ {documents.length > 0 && (
{documents.map((result) => {
const source = toSource(result, query, scope)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx
index c25f9b774e3..724f22c1929 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure.tsx
@@ -1,15 +1,16 @@
'use client'
import { type ReactNode, useId } from 'react'
-import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
+import { ChevronDown, cn, Expandable, ExpandableContent, handleKeyboardActivation } from '@sim/emcn'
import { ActivityViewport } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-viewport'
-interface ActivityDisclosureProps {
+export interface ActivityDisclosureProps {
header: ReactNode
children: ReactNode
expanded: boolean
onToggle: () => void
isStreaming: boolean
+ collapsible?: boolean
unbounded?: boolean
}
@@ -21,39 +22,51 @@ export function ActivityDisclosure({
onToggle,
isStreaming,
unbounded = false,
+ collapsible = true,
}: ActivityDisclosureProps) {
const contentId = useId()
const headerId = useId()
return (
-
-
+ handleKeyboardActivation(event, onToggle) : undefined}
+ className={cn(
+ 'flex w-full min-w-0 items-center gap-2 text-left',
+ collapsible && 'group/agent cursor-pointer'
+ )}
>
-
-
-
-
-
- {children}
-
-
-
+ {collapsible && (
+
+ )}
+
+ {collapsible && (
+
+
+
+
+
+ )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
new file mode 100644
index 00000000000..df297e5bdcb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
+import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
+import type { ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+
+function tool(id: string, status: ToolCallStatus = 'executing'): ToolCallData {
+ return { id, toolName: 'read', displayTitle: `Reading ${id}`, status }
+}
+
+function items(tools: ToolCallData[]): AgentGroupItem[] {
+ return tools.map((data) => ({ type: 'tool', data }))
+}
+
+describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (agentName) => {
+ let root: Root
+ let container: HTMLDivElement
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: false }))
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+ const render = (tools: ToolCallData[], active = true) =>
+ act(() =>
+ root.render(
+
+ )
+ )
+ const header = () => container.querySelector('[role="status"]')
+ const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms))
+
+ it('leaves empty lanes to the turn indicator and shows the first action immediately', () => {
+ render([])
+ expect(container.childElementCount).toBe(0)
+ advance(100)
+ render([tool('first')])
+ expect(header()?.textContent).toBe('Reading first')
+ const row = header()
+ render([tool('first', 'success')], false)
+ expect(header()?.textContent).toBe('Read first')
+ expect(header()).toBe(row)
+ })
+
+ it('preserves a successful model description in the header and expanded history', () => {
+ const completed = {
+ ...tool('first', 'success'),
+ activityDescription: 'Read the latest inbox emails',
+ }
+ render([completed], false)
+ expect(header()?.textContent).toBe(completed.activityDescription)
+ render([completed, tool('second', 'success')], false)
+ const trigger = container.querySelector
('[role="button"]')!
+ act(() => trigger.click())
+ expect(container.querySelector('[data-state="open"]')?.textContent).toContain(
+ completed.activityDescription
+ )
+ expect(container.textContent).not.toContain('Completed:')
+ })
+
+ it('shows the first action immediately and coalesces bursts without replaying a backlog', () => {
+ render([])
+ advance(100)
+ render([tool('first')])
+ expect(header()?.textContent).toBe('Reading first')
+ const row = header()
+ const shimmer = container.querySelector('[class*="shimmer"]')
+ advance(100)
+ render([tool('first', 'success')])
+ expect(header()?.textContent).toBe('Reading first')
+ expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
+ render([tool('first', 'success'), tool('second')])
+ expect(header()).toBe(row)
+ expect(container.querySelector('[class*="shimmer"]')).toBe(shimmer)
+ expect(header()?.textContent).toBe('Reading first')
+ advance(600)
+ render([tool('first', 'success'), tool('second', 'success'), tool('third')])
+ advance(299)
+ expect(header()?.textContent).toBe('Reading first')
+ advance(1)
+ expect(header()?.textContent).toBe('Reading third')
+ expect(container.textContent).not.toContain('Agent prefix')
+ advance(1000)
+ expect(header()?.textContent).toBe('Reading third')
+ })
+
+ it('keeps live history complete under a stable expanded header with keyboard disclosure', () => {
+ render([tool('first', 'success'), tool('second')])
+ const trigger = container.querySelector('[role="button"]')!
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
+ act(() => trigger.dispatchEvent(event))
+ expect(event.defaultPrevented).toBe(true)
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
+ expect(header()?.textContent).toBe('Tool activity')
+ render([tool('first', 'success'), tool('second', 'success'), tool('third')])
+ expect(header()?.textContent).toBe('Tool activity')
+ expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
+ 'Read firstRead secondReading third'
+ )
+ act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
+ expect(header()?.textContent).toBe('Reading third')
+ })
+
+ it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
+ 'shows %s immediately and cancels a pending cosmetic update',
+ (status) => {
+ render([tool('first')])
+ advance(100)
+ render([tool('first', 'success'), tool('second')])
+ render([tool('first', 'success'), tool('second', status)])
+ const prefix =
+ status === 'error' || status === 'rejected'
+ ? 'Failed'
+ : status === 'skipped'
+ ? 'Skipped'
+ : 'Stopped'
+ expect(header()?.textContent).toBe(`${prefix} reading second`)
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ advance(1500)
+ expect(header()?.textContent).toBe(`${prefix} reading second`)
+ }
+ )
+
+ it('shows final completion immediately and never replays the held action', () => {
+ render([tool('first')])
+ advance(100)
+ render([tool('first', 'success'), tool('second')])
+ render([tool('first', 'success'), tool('second', 'success')], false)
+ expect(header()?.textContent).toBe('Read files')
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ advance(2000)
+ expect(header()?.textContent).toBe('Read files')
+ })
+
+ it.each(['error', 'cancelled', 'interrupted', 'rejected', 'skipped'] as const)(
+ 'keeps an earlier parallel call active when the latest one becomes %s',
+ (status) => {
+ render([tool('first'), tool('second')])
+ advance(100)
+ render([tool('first'), tool('second', status)])
+ const outcome =
+ status === 'error' || status === 'rejected'
+ ? 'failed'
+ : status === 'skipped'
+ ? 'skipped'
+ : 'stopped'
+ expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
+ expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
+ advance(1000)
+ expect(header()?.textContent).toBe(`Reading first · 1 ${outcome}`)
+ }
+ )
+
+ it('surfaces an earlier parallel failure while the latest call keeps working', () => {
+ render([tool('first'), tool('second')])
+ advance(100)
+ render([tool('first', 'error'), tool('second')])
+ expect(header()?.textContent).toBe('Reading second · 1 failed')
+ })
+
+ it('keeps narration from prematurely completing an open lane', () => {
+ act(() =>
+ root.render(
+
+ )
+ )
+ const rows = container.querySelectorAll('[role="status"]')
+ if (agentName === 'mothership') {
+ expect(rows[0].textContent).toBe('Read first')
+ expect(rows[0].querySelector('[class*="shimmer"]')).toBeNull()
+ }
+ const liveRow = rows[rows.length - 1]
+ expect(liveRow.textContent).toBe('Reading second')
+ expect(liveRow.querySelector('[class*="shimmer"]')).not.toBeNull()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx
new file mode 100644
index 00000000000..df8539e20c2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { ActivityStatus, type ActivityStatusProps } from '@/components/ui/activity-status'
+import {
+ ActivityDisclosure,
+ type ActivityDisclosureProps,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
+
+const ACTIVITY_UPDATE_INTERVAL_MS = 1000
+
+interface ActivityStreamProps extends Omit {
+ activity: ActivityStatusProps
+ activityKey?: string
+ attentionKey: string
+ collapsible: boolean
+}
+
+/** Pace only the header; history and interactive tool state continue updating immediately. */
+export function ActivityStream({
+ activity,
+ activityKey,
+ attentionKey,
+ expanded,
+ collapsible,
+ children,
+ onToggle,
+ isStreaming,
+ unbounded,
+}: ActivityStreamProps) {
+ const isExpanded = collapsible && expanded
+ const key = `${activityKey}:${activity.label}`
+ const resetKey = `${activity.isActive}:${isExpanded}:${Boolean(activityKey)}:${attentionKey}`
+ const [visible, setVisible] = useState(() => ({
+ activity,
+ key,
+ resetKey,
+ shownAt: Date.now(),
+ }))
+
+ /** Completion, attention, and disclosure changes bypass the cosmetic delay. */
+ if (visible.resetKey !== resetKey) {
+ setVisible({ activity, key, resetKey, shownAt: Date.now() })
+ }
+
+ useEffect(() => {
+ if (!activity.isActive || isExpanded || key === visible.key) return
+ const remaining = Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt))
+ const flush = () => setVisible({ activity, key, resetKey, shownAt: Date.now() })
+ if (remaining === 0) {
+ flush()
+ return
+ }
+ const timer = setTimeout(flush, remaining)
+ return () => clearTimeout(timer)
+ }, [activity, key, resetKey, isExpanded, visible.key, visible.shownAt])
+
+ const displayed =
+ !activity.isActive || isExpanded || key === visible.key ? activity : visible.activity
+ const header = (
+
+ )
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts
new file mode 100644
index 00000000000..e9f93a653d5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content.ts
@@ -0,0 +1,44 @@
+import type {
+ AgentGroupItem,
+ NestedAgentGroup,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
+import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
+
+/** Empty agent lanes share the turn's thinking indicator until they have output. */
+export function hasAgentGroupItemContent(item: AgentGroupItem): boolean {
+ switch (item.type) {
+ case 'tool':
+ return true
+ case 'text':
+ return item.content.trim().length > 0
+ case 'agent_group':
+ return item.group.items.some(hasAgentGroupItemContent)
+ }
+}
+
+/** Finds empty live lanes at any depth whose wait belongs to the turn indicator. */
+export function hasPendingAgentGroup(
+ group: Pick
+): boolean {
+ return (
+ ((group.isOpen || group.isDelegating) && !group.items.some(hasAgentGroupItemContent)) ||
+ group.items.some((item) => item.type === 'agent_group' && hasPendingAgentGroup(item.group))
+ )
+}
+
+/**
+ * Every tool in a group, in stream order, including those run by nested
+ * agents. A parent's status line speaks for the whole subtree it delegated,
+ * so a grandchild's work is what surfaces while the parent itself waits.
+ */
+export function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
+ const tools: ToolCallData[] = []
+ const walk = (list: AgentGroupItem[]) => {
+ for (const item of list) {
+ if (item.type === 'tool') tools.push(item.data)
+ else if (item.type === 'agent_group') walk(item.group.items)
+ }
+ }
+ walk(items)
+ return tools
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx
index c9733459807..fd9b7a5797a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx
@@ -1,16 +1,28 @@
'use client'
-import { type ComponentType, type ReactNode, useMemo, useState } from 'react'
-import { ActivityStatus } from '@/components/ui/activity-status'
+import { type ComponentType, type ReactNode, useState } from 'react'
+import { ThinkingLoader } from '@/components/ui/thinking-loader'
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
-import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
+import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
+import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
+import {
+ collectGroupTools,
+ hasAgentGroupItemContent,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content'
import { BrowserAgentIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon'
import { renderInlineMarkdown } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown'
import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity'
-import { getToolActivitySummary } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
+import {
+ getActiveToolActivityTitle,
+ getActivityStatusTool,
+ getToolActivitySummary,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
-import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
+import {
+ getActivityAttentionKey,
+ needsToolInput,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
import {
getAgentIcon,
isToolDone,
@@ -43,7 +55,7 @@ export interface AgentGroupProps {
items: AgentGroupItem[]
isDelegating?: boolean
isStreaming?: boolean
- /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */
+ /** This lane can receive work; main lanes close when a later transcript segment begins. */
isLaneOpen?: boolean
/** Opens a subagent group on first render. */
defaultExpanded?: boolean
@@ -51,25 +63,13 @@ export interface AgentGroupProps {
autoScrollActivity?: boolean
}
-function toolStatusTitle(tool: ToolCallData): string {
- return tool.displayTitle || String(tool.toolName ?? '')
-}
-
-/**
- * Every tool in a group, in stream order, including those run by nested
- * agents. A parent's status line speaks for the whole subtree it delegated,
- * so a grandchild's work is what surfaces while the parent itself waits.
- */
-function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
- const tools: ToolCallData[] = []
- const walk = (list: AgentGroupItem[]) => {
- for (const item of list) {
- if (item.type === 'tool') tools.push(item.data)
- else if (item.type === 'agent_group') walk(item.group.items)
- }
- }
- walk(items)
- return tools
+function activeToolTitle(tool: ToolCallData): string {
+ return getToolStatusDisplayTitle(
+ tool.displayTitle || String(tool.toolName ?? ''),
+ tool.status === ToolCallStatus.success ? ToolCallStatus.executing : tool.status,
+ tool.toolName,
+ tool.activityDescription
+ )
}
/** Reveal blocking interactions even when a parent group was manually collapsed. */
@@ -140,7 +140,6 @@ interface AgentGroupViewProps extends AgentGroupProps {
export function AgentGroupView({
agentName,
- agentLabel,
items,
isDelegating = false,
isStreaming = false,
@@ -151,35 +150,9 @@ export function AgentGroupView({
renderBrowserTakeover,
}: AgentGroupViewProps) {
const AgentIcon = getAgentIcon(agentName)
- const agentIcon =
- agentName === 'browser' ? (
-
- ) : (
-
- )
const isMainAgent = agentName === 'mothership'
- /** Open lanes surface their latest work, including work delegated to nested agents. */
- const status = useMemo(() => {
- if (isMainAgent || !isLaneOpen) return undefined
- const tools = collectGroupTools(items)
- const running = tools.filter((tool) => tool.status === ToolCallStatus.executing)
- if (running.length > 0) {
- const latest = running.reduce((newest, tool) =>
- (tool.startedAt ?? 0) >= (newest.startedAt ?? 0) ? tool : newest
- )
- const title = toolStatusTitle(latest)
- return running.length > 1 ? `${title} + ${running.length - 1}` : title
- }
- const last = tools.at(-1)
- return last ? toolStatusTitle(last) : undefined
- }, [isLaneOpen, isMainAgent, items])
- const completedTools = !isMainAgent && !isLaneOpen ? collectGroupTools(items) : []
- const headerText = status
- ? `${agentLabel} — ${status}`
- : completedTools.length > 0
- ? `${agentLabel} — ${getToolActivitySummary(completedTools)}`
- : agentLabel
- const hasItems = items.length > 0
+ const tools = isMainAgent ? [] : collectGroupTools(items)
+ const statusTool = getActivityStatusTool(tools)
const resolved = isAgentGroupResolved(items)
const browserAgentAvailable = isBrowserAgentAvailable()
const activeBrowserTakeover =
@@ -187,6 +160,14 @@ export function AgentGroupView({
const nestedBrowserTakeover = browserAgentAvailable && hasNestedBrowserTakeover(items)
const isWorking =
!activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen))
+ const agentIcon =
+ isWorking && !statusTool ? (
+
+ ) : agentName === 'browser' ? (
+
+ ) : (
+
+ )
const [manualExpanded, setManualExpanded] = useState(defaultExpanded)
const [expandedTakeoverId, setExpandedTakeoverId] = useState(null)
@@ -197,6 +178,9 @@ export function AgentGroupView({
nestedBrowserTakeover ||
(activeBrowserTakeover ? expandedTakeoverId === activeBrowserTakeover.id : manualExpanded)
+ const meaningfulItems = items.filter(hasAgentGroupItemContent)
+ if (meaningfulItems.length === 0) return null
+
const toggleExpanded = () => {
if (activeBrowserTakeover) {
setExpandedTakeoverId(expanded ? null : activeBrowserTakeover.id)
@@ -213,6 +197,7 @@ export function AgentGroupView({
toolCallId={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
+ activityDescription={item.data.activityDescription}
status={item.data.status}
params={item.data.params}
result={item.data.result}
@@ -237,6 +222,7 @@ export function AgentGroupView({
/>
)
}
+ if (!item.content.trim()) return null
return (
) : (
{items.map(renderItem)}
)
- const header =
+ const headerText = isWorking
+ ? statusTool
+ ? getActiveToolActivityTitle(activeToolTitle(statusTool), statusTool, tools)
+ : 'Thinking'
+ : tools.length > 0
+ ? getToolActivitySummary(tools)
+ : 'Tool activity'
+ const headerActive =
+ isWorking &&
+ (!statusTool ||
+ statusTool.status === ToolCallStatus.executing ||
+ statusTool.status === ToolCallStatus.success)
+ const collapsible =
+ meaningfulItems.length > 1 ||
+ meaningfulItems.some(
+ (item) =>
+ item.type !== 'tool' ||
+ needsToolInput(item.data) ||
+ item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID
+ )
return (
{isMainAgent ? (
activity
- ) : hasItems ? (
-
{activity}
-
- ) : (
- header
+
)}
{activeBrowserTakeover && (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
index 8dd4c725517..1b1075a60ce 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
@@ -118,8 +118,47 @@ describe('AgentGroup inline main activity', () => {
afterEach(() => {
act(() => root.unmount())
container.remove()
+ vi.useRealTimers()
})
+ it.each(['mothership', 'workflow', 'browser'])(
+ 'shows one model-described action without a redundant %s disclosure',
+ (agentName) => {
+ act(() =>
+ root.render(
+ createElement(AgentGroup, {
+ agentName,
+ agentLabel: agentName,
+ defaultExpanded: true,
+ isLaneOpen: true,
+ isStreaming: true,
+ items: [
+ {
+ type: 'tool',
+ data: {
+ id: 'described-read',
+ toolName: 'read',
+ displayTitle: 'Reading files',
+ activityDescription: 'Checking the project timeline',
+ status: 'executing',
+ },
+ },
+ ],
+ })
+ )
+ )
+
+ const statuses = [...container.querySelectorAll('[role="status"]')]
+ expect(statuses).toHaveLength(1)
+ expect(container.querySelector
('[role="button"]')).toBeNull()
+ for (const status of statuses) {
+ expect(status.textContent).toContain('Checking the project timeline')
+ }
+ expect(container.textContent).not.toContain('Reading files')
+ expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
+ }
+ )
+
it.each([
['executing', 'Reading notes'],
['success', 'Read notes'],
@@ -151,12 +190,13 @@ describe('AgentGroup inline main activity', () => {
)
expect(container.textContent).toBe(expected)
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
- expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role="button"]')).toBeNull()
expect(container.querySelector('[data-state]')).toBeNull()
expect(Boolean(container.querySelector('[class*="shimmer"]'))).toBe(status === 'executing')
})
- it('replaces the active status in place and expands the full completed history', () => {
+ it('paces the active status in place and expands the full completed history', () => {
+ vi.useFakeTimers()
const first: AgentGroupItem = {
type: 'tool',
data: { id: 'first', toolName: 'grep', displayTitle: 'Searching files', status: 'executing' },
@@ -180,14 +220,18 @@ describe('AgentGroup inline main activity', () => {
render([first])
expect(container.textContent).toBe('Searching files')
- expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role="button"]')).toBeNull()
const activity = container.firstElementChild
render([first, next])
expect(container.firstElementChild).toBe(activity)
+ expect(container.textContent).toBe('Searching files')
+ act(() => vi.advanceTimersByTime(1000))
expect(container.textContent).toBe('Reading notes')
expect(container.querySelector('[class*="shimmer"]')).not.toBeNull()
- expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
+ expect(
+ container.querySelector('[role="button"]')?.getAttribute('aria-expanded')
+ ).toBe('false')
expect(container.querySelector('svg')).not.toBeNull()
expect(container.textContent).not.toContain('Sim')
@@ -200,7 +244,7 @@ describe('AgentGroup inline main activity', () => {
)
expect(container.textContent).toBe('Searched files, read files')
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
- const header = container.querySelector('button')
+ const header = container.querySelector('[role="button"]')
act(() => header?.click())
expect(header?.getAttribute('aria-expanded')).toBe('true')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
@@ -237,7 +281,7 @@ describe('AgentGroup inline main activity', () => {
)
)
render([first, second])
- act(() => container.querySelector('button')?.click())
+ act(() => container.querySelector('[role="button"]')?.click())
render([
first,
second,
@@ -251,7 +295,9 @@ describe('AgentGroup inline main activity', () => {
},
},
])
- expect(container.querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
+ expect(
+ container.querySelector('[role="button"]')?.getAttribute('aria-expanded')
+ ).toBe('true')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'Read notesRead more notesRunning checks'
)
@@ -290,15 +336,15 @@ describe('AgentGroup inline main activity', () => {
render([wait])
act(() => vi.advanceTimersByTime(2000))
expect(container.textContent).toBe('Waiting 1s')
- expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role="button"]')).toBeNull()
render([wait, read])
expect(container.textContent).toBe('Waiting 1s')
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
- const header = container.querySelector('button')
+ const header = container.querySelector('[role="button"]')
act(() => header?.click())
expect(header?.hasAttribute('aria-label')).toBe(false)
- expect(header?.textContent).toBe('Waiting 1s')
- expect(header).toHaveAccessibleName('Waiting 1s')
+ expect(header?.textContent).toBe('Tool activity')
+ expect(header).toHaveAccessibleName('Tool activity')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'Waiting 1sRead notes'
)
@@ -314,8 +360,8 @@ describe('AgentGroup inline main activity', () => {
read,
{ ...wait, data: { ...wait.data, id: 'wait-second' } },
])
- expect(header?.textContent).toBe('Waiting 3s')
- expect(header).toHaveAccessibleName('Waiting 3s')
+ expect(header?.textContent).toBe('Tool activity')
+ expect(header).toHaveAccessibleName('Tool activity')
expect(container.querySelector('.overflow-y-auto')).toBe(viewport)
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
'WaitedRead notesWaiting 3s'
@@ -367,9 +413,9 @@ describe('AgentGroup inline main activity', () => {
})
)
)
- const header = container.querySelector('button')
- expect(header?.textContent).toBe('Agent — Read files, ran commands')
- expect(header).toHaveAccessibleName('Agent — Read files, ran commands')
+ const header = container.querySelector('[role="button"]')
+ expect(header?.textContent).toBe('Read files, ran commands')
+ expect(header).toHaveAccessibleName('Read files, ran commands')
expect(container.querySelectorAll('[data-tool-call-id]')).toHaveLength(0)
act(() => header?.click())
expect(
@@ -406,12 +452,19 @@ describe('AgentGroup inline main activity', () => {
],
ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => {
const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle)
- return renderStatus ? renderStatus(status) : status
+ return renderStatus
+ ? renderStatus({
+ label: displayTitle,
+ activeLabel: displayTitle,
+ isActive: true,
+ icon: createElement('svg', { 'data-tool-call-id': toolCallId }),
+ })
+ : status
},
})
)
)
- const headers = Array.from(container.querySelectorAll('button'))
+ const headers = Array.from(container.querySelectorAll('[role="button"]'))
expect(headers).toHaveLength(2)
expect(headers.every((header) => header.getAttribute('aria-expanded') === 'true')).toBe(true)
act(() => headers[0].click())
@@ -512,7 +565,14 @@ describe('AgentGroup inline main activity', () => {
isStreaming: true,
ToolCallComponent: ({ toolCallId, displayTitle, renderStatus }: ToolCallItemProps) => {
const status = createElement('div', { 'data-tool-call-id': toolCallId }, displayTitle)
- return renderStatus ? renderStatus(status) : status
+ return renderStatus
+ ? renderStatus({
+ label: displayTitle,
+ activeLabel: displayTitle,
+ isActive: true,
+ icon: createElement('svg', { 'data-tool-call-id': toolCallId }),
+ })
+ : status
},
})
)
@@ -559,8 +619,8 @@ describe('AgentGroup browser takeover', () => {
expect(liftedQuestion).toBeDefined()
expect(collapsedLog?.contains(liftedQuestion ?? null)).toBe(false)
- const header = Array.from(container.querySelectorAll('button')).find((button) =>
- button.textContent?.includes('Browser Agent')
+ const header = Array.from(container.querySelectorAll('[role="button"]')).find(
+ (button) => button.hasAttribute('aria-expanded')
)
act(() => header?.click())
expect(container.querySelector('[data-state="open"]')).not.toBeNull()
@@ -648,7 +708,7 @@ describe('AgentGroup browser takeover', () => {
expect(container.querySelector('.animate-stream-fade-in')).toBeNull()
// Groups never auto-expand: the answered question lives inside the
// collapsed log until the user opens it manually.
- const headerToggle = container.querySelector('button[class*="group/agent"]')
+ const headerToggle = container.querySelector('[role="button"][class*="group/agent"]')
expect(headerToggle).not.toBeNull()
act(() => {
headerToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
@@ -713,10 +773,10 @@ describe('AgentGroup nested status line', () => {
namedTool('Reading workflow', 'success' as ToolCallStatus, 1),
group([namedTool('Deploying Invoice Sync as API', 'executing' as ToolCallStatus, 2)]),
])
- expect(header).toContain('Workflow Agent — Deploying Invoice Sync as API')
+ expect(header).toContain('Deploying Invoice Sync as API')
})
- it('counts running tools across depths with the + n suffix', () => {
+ it('selects the latest running tool across depths', () => {
const header = render([
namedTool('Reading workflow', 'executing' as ToolCallStatus, 1),
group([
@@ -724,8 +784,8 @@ describe('AgentGroup nested status line', () => {
namedTool('Checking deployment status', 'executing' as ToolCallStatus, 2),
]),
])
- // Latest start wins; the other two running become the overflow count.
- expect(header).toContain('Deploying Invoice Sync as API + 2')
+ /** The latest start wins across the subtree. */
+ expect(header).toContain('Deploying Invoice Sync as API')
})
it('falls back to the last tool at any depth when nothing is running', () => {
@@ -733,6 +793,6 @@ describe('AgentGroup nested status line', () => {
namedTool('Reading workflow', 'success' as ToolCallStatus, 1),
group([namedTool('Deploying Invoice Sync as API', 'success' as ToolCallStatus, 2)]),
])
- expect(header).toContain('Workflow Agent — Deploying Invoice Sync as API')
+ expect(header).toContain('Deploying Invoice Sync as API')
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx
index 70b38e2bf03..13ee89ed18f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/browser-agent-icon.test.tsx
@@ -4,6 +4,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { AgentGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group'
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
import {
BrowserAgentIcon,
@@ -227,6 +228,58 @@ describe('BrowserAgentIcon', () => {
)
}
+ it('keeps a loaded favicon paired with the paced label when disclosure appears and the site changes', () => {
+ vi.useFakeTimers()
+ const first = tool({
+ id: 'first',
+ displayTitle: 'Opening first page',
+ result: undefined,
+ status: 'executing',
+ params: { url: 'https://example.com/' },
+ })
+ const second = tool({
+ id: 'second',
+ displayTitle: 'Opening second page',
+ result: undefined,
+ status: 'executing',
+ params: { url: 'https://example.org/' },
+ })
+ const renderGroup = (items: AgentGroupItem[]) =>
+ act(() =>
+ root.render(
+
+ )
+ )
+ try {
+ openPage('https://example.com/')
+ renderGroup([first])
+ const img = container.querySelector('img')!
+ act(() => img.dispatchEvent(new Event('load')))
+ act(() => vi.advanceTimersByTime(100))
+ openPage('https://example.org/')
+ renderGroup([first, second])
+ expect(container.querySelector('img')).toBe(img)
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Opening first page')
+ expect(container.querySelector('[role="button"]')).not.toBeNull()
+ act(() => vi.advanceTimersByTime(900))
+ expect(container.querySelector('[role="status"]')?.textContent).toBe('Opening second page')
+ const nextImage = container.querySelector('img')!
+ expect(nextImage).not.toBe(img)
+ expect(nextImage.src).toBe('https://example.org/favicon.ico')
+ act(() => nextImage.dispatchEvent(new Event('error')))
+ expect(container.querySelector('img')).toBeNull()
+ expect(container.querySelector('[role="status"] svg')).not.toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('does not contact sites from history, other chats, or pending navigation', () => {
render('https://example.com/document')
expect(container.querySelector('img')).toBeNull()
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx
index 82e62b4e3a4..140ddea8e48 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx
@@ -11,6 +11,7 @@ interface MainAgentActivityProps {
ToolCallComponent: ComponentType
renderItem: (item: AgentGroupItem, index: number) => ReactNode
autoScrollActivity: boolean
+ isActive: boolean
}
/** Keep answers and interactions in the transcript, outside collapsible tool history. */
@@ -27,15 +28,17 @@ export function MainAgentActivity({
ToolCallComponent,
renderItem,
autoScrollActivity,
+ isActive,
}: MainAgentActivityProps) {
const activity: ReactNode[] = []
let tools: ToolCallData[] = []
- const flushTools = () => {
+ const flushTools = (active = false) => {
if (tools.length === 0) return
activity.push(
@@ -63,7 +66,7 @@ export function MainAgentActivity({
)
}
- flushTools()
+ flushTools(isActive)
return {activity}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
index e0b17ababbf..e1c23b42d1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
@@ -24,7 +24,18 @@ describe('getToolActivitySummary', () => {
tool('browser_type'),
tool('browser_navigate'),
])
- ).toBe('Navigated pages, read pages +1 more')
+ ).toBe('Navigated, read pages +1 more')
+ })
+
+ it.each([
+ [['browser_navigate', 'browser_read_text'], 'Navigated, read pages'],
+ [['browser_read_text', 'browser_navigate'], 'Read, navigated pages'],
+ [['browser_navigate', 'browser_read_text', 'browser_scroll'], 'Navigated, read pages +1 more'],
+ [['browser_navigate', 'browser_type'], 'Navigated pages, entered text'],
+ [['browser_navigate', 'browser_navigate'], 'Navigated pages'],
+ [['read', 'browser_read_text'], 'Read files, read pages'],
+ ])('compacts only explicit shared objects: %j', (names, expected) => {
+ expect(getToolActivitySummary((names as string[]).map((name) => tool(name)))).toBe(expected)
})
it('does not describe unsuccessful work as completed actions', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
index d426fe587da..11b95a3833e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx
@@ -2,10 +2,11 @@
import { type ComponentType, Fragment, useState } from 'react'
import { ActivityStatus } from '@/components/ui/activity-status'
-import { getToolActivityLabel } from '@/lib/copilot/tools/tool-activity'
+import { getToolActivitySummaryActions } from '@/lib/copilot/tools/tool-activity'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
-import { ActivityDisclosure } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-disclosure'
+import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
+import { getActivityAttentionKey } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
import { getToolIcon } from '@/app/workspace/[workspaceId]/home/components/message-content/utils'
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
@@ -15,94 +16,126 @@ const MAX_SUMMARY_ACTIONS = 2
export function getToolActivitySummary(tools: ToolCallData[]): string {
if (tools.length === 1) {
const tool = tools[0]
- return getToolStatusDisplayTitle(tool.displayTitle, tool.status, tool.toolName)
+ return getToolStatusDisplayTitle(
+ tool.displayTitle,
+ tool.status,
+ tool.toolName,
+ tool.activityDescription
+ )
}
- const labels = new Set()
+ const { labels, additionalActions } = getToolActivitySummaryActions(
+ tools.filter((tool) => tool.status === ToolCallStatus.success),
+ MAX_SUMMARY_ACTIONS
+ )
+ const summary = labels.join(', ')
+ const summaryLabel = summary ? summary[0].toUpperCase() + summary.slice(1) : 'Tool activity'
+ return [
+ additionalActions > 0 ? `${summaryLabel} +${additionalActions} more` : summaryLabel,
+ ...getToolActivityOutcomes(tools),
+ ].join(' · ')
+}
+
+function getToolActivityOutcomes(tools: ToolCallData[]): string[] {
let failed = 0
let stopped = 0
let skipped = 0
for (const tool of tools) {
- if (tool.status === ToolCallStatus.success) {
- labels.add(getToolActivityLabel(tool.toolName, tool.params))
- } else if (tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected)
- failed++
+ if (tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected) failed++
else if (tool.status === ToolCallStatus.cancelled || tool.status === ToolCallStatus.interrupted)
stopped++
else if (tool.status === ToolCallStatus.skipped) skipped++
}
- const summary = Array.from(labels).slice(0, MAX_SUMMARY_ACTIONS).join(', ')
- const summaryLabel = summary ? summary[0].toUpperCase() + summary.slice(1) : 'Tool activity'
- const additionalActions = Math.max(0, labels.size - MAX_SUMMARY_ACTIONS)
- const outcomes = [
- failed && `${failed} failed`,
- stopped && `${stopped} stopped`,
- skipped && `${skipped} skipped`,
- ].filter(Boolean)
return [
- additionalActions > 0 ? `${summaryLabel} +${additionalActions} more` : summaryLabel,
- ...outcomes,
- ].join(' · ')
+ ...(failed ? [`${failed} failed`] : []),
+ ...(stopped ? [`${stopped} stopped`] : []),
+ ...(skipped ? [`${skipped} skipped`] : []),
+ ]
+}
+
+/** Keep earlier parallel failures visible while the latest action continues. */
+export function getActiveToolActivityTitle(
+ label: string,
+ tool: ToolCallData,
+ tools: ToolCallData[]
+): string {
+ return tool.status === ToolCallStatus.executing || tool.status === ToolCallStatus.success
+ ? [label, ...getToolActivityOutcomes(tools)].join(' · ')
+ : label
+}
+
+/** Keep running work visible until every parallel call finishes. */
+export function getActivityStatusTool(tools: ToolCallData[]): ToolCallData | undefined {
+ return (
+ tools.reduce(
+ (newest, tool) =>
+ tool.status === ToolCallStatus.executing &&
+ (!newest || (tool.startedAt ?? 0) >= (newest.startedAt ?? 0))
+ ? tool
+ : newest,
+ undefined
+ ) ?? tools.at(-1)
+ )
}
interface ToolActivityGroupProps {
tools: ToolCallData[]
ToolCallComponent: ComponentType
autoScrollActivity?: boolean
+ isActive?: boolean
}
export function ToolActivityGroup({
tools,
ToolCallComponent,
autoScrollActivity = true,
+ isActive = false,
}: ToolActivityGroupProps) {
const [expanded, setExpanded] = useState(false)
- let activeTool: ToolCallData | undefined
- for (let index = tools.length - 1; index >= 0; index--) {
- if (tools[index].status === ToolCallStatus.executing) {
- activeTool = tools[index]
- break
- }
- }
- const statusTool = activeTool ?? tools[tools.length - 1]
+ const statusTool = getActivityStatusTool(tools)
+ if (!statusTool) return null
+ const working = isActive || tools.some((tool) => tool.status === ToolCallStatus.executing)
+ const headerActive =
+ working &&
+ (statusTool.status === ToolCallStatus.executing || statusTool.status === ToolCallStatus.success)
+ const attentionKey = getActivityAttentionKey(tools)
const SummaryIcon = getToolIcon(tools[0].toolName)
return (
{
- if (tools.length === 1) return status
- return (
- }
- />
- )
- }
- expanded={expanded}
- onToggle={() => setExpanded(!expanded)}
- isStreaming={Boolean(activeTool) && autoScrollActivity}
- >
-
- {tools.map((tool) => (
-
- {tool.id === statusTool.id ? (
- status
- ) : (
-
- )}
-
- ))}
-
-
- )
- }}
+ renderStatus={(status) => (
+ ,
+ }}
+ activityKey={statusTool.id}
+ attentionKey={attentionKey}
+ collapsible={tools.length > 1}
+ expanded={expanded}
+ onToggle={() => setExpanded(!expanded)}
+ isStreaming={working && autoScrollActivity}
+ >
+
+ {tools.map((tool) => (
+
+ {tool.id === statusTool.id ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ )}
/>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
index b52d7b1b0e6..1692702faec 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
@@ -5,9 +5,11 @@ import { act, type ReactNode, type SVGProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
+import { ToolCallItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
+import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
import { getBlock, getBlockByToolName } from '@/blocks/registry'
-import { ToolCallItem } from './tool-call-item'
vi.mock('@/components/ui', () => ({
ShimmerText: ({ children }: { children: ReactNode }) => {children} ,
@@ -48,6 +50,90 @@ describe('ToolCallItem', () => {
expect(markup).not.toContain('Writing brief.md')
})
+ it.each([
+ ['executing', 'Checking the invoice totals'],
+ ['success', 'Checked the invoice totals'],
+ ['error', 'Failed checking the invoice totals'],
+ ['cancelled', 'Stopped checking the invoice totals'],
+ ['rejected', 'Failed checking the invoice totals'],
+ ['skipped', 'Skipped checking the invoice totals'],
+ ] as const)(
+ 'projects %s from the actual tool status onto the model description',
+ (status, title) => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain(title)
+ expect(markup).not.toContain('report.md')
+ }
+ )
+
+ it.each([' ', 'a'.repeat(161)])(
+ 'uses the existing title for an invalid description',
+ (activityDescription) => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('Searching files')
+ }
+ )
+
+ it('keeps an executing wait countdown in place of the model phrase', () => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('10s')
+ expect(markup).not.toContain('Waiting for the export')
+ })
+
+ it('renders model descriptions as text, without interpreting markup', () => {
+ const markup = renderToStaticMarkup(
+
+ )
+
+ expect(markup).toContain('<script>')
+ expect(markup).not.toContain('