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/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index 1f7887a2de1..d6f5b3f806b 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -132,7 +132,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/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 7dbc4f15fa5..55848547f6c 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -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/index.mdx b/apps/docs/content/docs/search/index.mdx index 68b53e20f2a..063d27d9243 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. 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..ff657118256 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -2,13 +2,13 @@ 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 { secureFetchWithPinnedIP, validateUrlWithDNS, @@ -116,18 +116,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 +144,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 +174,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 +245,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 +280,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { providerId, issuer, domain, - ...(orgId ? { organizationId: orgId } : {}), + organizationId: orgId, } if (providerType === 'oidc') { @@ -292,13 +298,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) @@ -632,18 +631,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 +655,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 +673,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .returning({ id: ssoProvider.id }) return granted.length > 0 }) - } if (existingOwnedProvider) { const revertProviderUpdate = async (): Promise => { @@ -784,12 +754,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/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/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 = ( - - - - - - - - - - - - - - - - ) - - /** - * 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]/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({

))} 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/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]/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 ( -
- - - - - {children} - - - + {collapsible && ( + + )} +
+ {collapsible && ( + + +
+ + {children} + +
+
+
+ )} ) } 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..9566b47c9fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx @@ -0,0 +1,175 @@ +/** + * @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'])('%s activity cadence', (agentName) => { + let root: Root + let container: HTMLDivElement + beforeEach(() => { + vi.useFakeTimers() + ;(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() + }) + const render = (tools: ToolCallData[], active = true) => + act(() => + root.render( + + ) + ) + const header = () => container.querySelector('[role="status"]') + const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms)) + + 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-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index c9733459807..fc41c6ae98b 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,23 @@ '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 { 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 { 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 +50,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,8 +58,13 @@ export interface AgentGroupProps { autoScrollActivity?: boolean } -function toolStatusTitle(tool: ToolCallData): string { - return tool.displayTitle || String(tool.toolName ?? '') +function activeToolTitle(tool: ToolCallData): string { + return getToolStatusDisplayTitle( + tool.displayTitle || String(tool.toolName ?? ''), + tool.status === ToolCallStatus.success ? ToolCallStatus.executing : tool.status, + tool.toolName, + tool.activityDescription + ) } /** @@ -140,7 +152,6 @@ interface AgentGroupViewProps extends AgentGroupProps { export function AgentGroupView({ agentName, - agentLabel, items, isDelegating = false, isStreaming = false, @@ -158,28 +169,8 @@ export function AgentGroupView({ ) 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 = @@ -213,6 +204,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} @@ -252,28 +244,49 @@ export function AgentGroupView({ ToolCallComponent={ToolCallComponent} renderItem={renderItem} autoScrollActivity={autoScrollActivity} + isActive={isStreaming && isLaneOpen} /> ) : (
{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 = + items.length > 1 || + items.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('