diff --git a/apps/docs/content/docs/search/ask-questions.mdx b/apps/docs/content/docs/search/ask-questions.mdx index 2bbb9f35e5f..3776009270a 100644 --- a/apps/docs/content/docs/search/ask-questions.mdx +++ b/apps/docs/content/docs/search/ask-questions.mdx @@ -127,19 +127,21 @@ Copy the reviewed draft into another app, or ask Assistant to create it using yo When integration actions are enabled, Assistant can answer questions and take supported actions in the same conversation. For example, ask it to draft an email in Gmail or create a Jira issue using your own connected account. -Reads run without a prompt by default. Writes wait for review by default. Each proposal appears inline in the conversation with its app, account, destination, and proposed content. Edit the supported fields, then select **Allow** or **Cancel** for each action. After several proposals, **Allow N shown** or **Cancel N shown** applies your choice to that displayed batch. You can approve individual actions first, then cancel the remaining batch. +Reads run without a prompt by default. Writes wait for review by default. Each proposal appears inline in the conversation with its app, account, destination, and proposed content. Select **Edit** to change supported fields, then choose **Allow** or **Cancel** for each action. After several proposals, **Allow N shown** or **Cancel N shown** applies your choice to that displayed batch. You can approve individual actions first, then cancel the remaining batch. Inline Assistant action review showing the connected Jira account, exact site and project, editable issue summary and description, and Allow or Cancel controls +Decisions remain in the conversation as expandable receipts after reload. **Approved** means review is complete; the status changes to **Running**, **Completed**, or **Failed** as execution proceeds. **Cancelled** records a skipped proposal. **Outcome unknown** means you should check the original app before repeating the action. + Each decision applies to that proposal. Changing its account or destination requires a new proposal. An action can still fail if your account is disconnected or an administrator changes access before execution. If the provider outcome is unknown, check the original app before asking Assistant to repeat a write. -Configure persistent **Allow**, **Ask**, or **Block** preferences under **Integrations → ⋯ → Tool permissions** for your account. See [tool permissions](/search/connect-your-account#tool-permissions). +Configure persistent **Allow**, **Ask**, or **Block** preferences under **Integrations → app → account**. See [tool permissions](/search/connect-your-account#tool-permissions). ## Start from a document diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 181194b1972..53968005c5a 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -10,19 +10,19 @@ After your admin adds a source in **Settings → Sources**, connect your own acc ## Connect and verify 1. Join the Sim organization through its invitation or SSO. Use the verified email required by the provider connection. -2. Open **Integrations**, find the provider, and choose **Connect** from its **⋯** menu. +2. Open **Integrations**, select the app, and choose **Connect** in its header. 3. Complete authorization in the new tab. If the popup is blocked, allow it and select **Connect** again. 4. Return to **Search** or **Search → Assistant** and search for content your account can open. Read a result and follow its source link. The Confluence menu in Integrations with Connect available before an account has been authorized -If your organization makes another connection available, choose **Add account** from the same menu. +If your organization makes another connection available, choose **Add account** in the header. When several accounts are connected, select an account to see its permissions and connection actions. The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention. @@ -38,7 +38,7 @@ You do not choose repositories, channels, labels, or an indexing method during p | Confluence service account | Connect Confluence. Both your account and the service connection must read the selected content. | | Coda | Connect personal Coda MCP. Service mode also verifies the parent document with its REST token. | | GitLab | No personal connection. Your verified Sim email must match current GitLab identity and permissions, or the admin's CSV mapping and project grant. | -| [Generic Secrets, Member mode](/search/generic-secrets) | Select **Manage secrets** in Integrations and save your own values for organization Home conversations. | +| [Generic Secrets, Member mode](/search/generic-secrets) | Open **Generic Secrets** in Integrations and save your own values for organization Home conversations. | Gmail, Calendar, and Drive have separate grants. Connecting one does not authorize the others. Except for GitLab's permission model, a service account restricts resources rather than replacing your personal connection. @@ -46,7 +46,7 @@ Creating Jira sprints requires the Jira sprint permission. If your account was c ## Tool permissions -When Assistant integration actions are enabled, open the app’s **⋯** menu in **Integrations** and select **Tool permissions** for your connected account. This option appears when the app has supported Assistant tools available to you. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: +When Assistant integration actions are enabled, open the app in **Integrations** and select your account. Tool permissions appear in its details when the app has supported Assistant tools available to you. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: | Preference | Behavior | | --- | --- | @@ -66,13 +66,13 @@ These preferences belong to your account. They cannot grant access to another pe variant="overview" /> -Apps used only for actions appear under **Assistant apps**. An admin makes the app available in organization source settings, then each member connects their own account in Integrations. +Search sources and apps used for actions share the same Integrations list. An admin makes the app available in organization source settings, then each member connects their own account. Configured apps remain visible when disabled; their connection actions are unavailable, and existing accounts can still be inspected or disconnected. ## Reconnect or disconnect -Choose **Reconnect** from the app’s **⋯** menu when a grant expires, is revoked, or needs more scopes. A temporary provider outage does not necessarily require reconnecting. +Open the app and select the account, then choose **Reconnect** in the header when a grant expires, is revoked, or needs more scopes. A temporary provider outage does not necessarily require reconnecting. -Choose **Disconnect** from the same menu to stop future use of the grant. This can affect workflows sharing the account. Review the confirmation before removing it. Disconnecting does not remove your Sim membership. +Choose **Disconnect** in the same header to stop future use of the grant. This can affect workflows sharing the account. Review the confirmation before removing it. Disconnecting does not remove your Sim membership. For an account-connection request, sign in as the verified Sim user associated with the requested email, then authorize the provider. The request does not invite you into the organization; membership and Search access are still required. diff --git a/apps/docs/public/static/search/assistant-action-review.png b/apps/docs/public/static/search/assistant-action-review.png index 6dbbddc6454..167434244f9 100644 Binary files a/apps/docs/public/static/search/assistant-action-review.png and b/apps/docs/public/static/search/assistant-action-review.png differ diff --git a/apps/docs/public/static/search/assistant-tool-permissions.png b/apps/docs/public/static/search/assistant-tool-permissions.png index 5d8aac853ba..90dd2f891ad 100644 Binary files a/apps/docs/public/static/search/assistant-tool-permissions.png and b/apps/docs/public/static/search/assistant-tool-permissions.png differ diff --git a/apps/docs/public/static/search/connect-account-details.png b/apps/docs/public/static/search/connect-account-details.png new file mode 100644 index 00000000000..565965e7773 Binary files /dev/null and b/apps/docs/public/static/search/connect-account-details.png differ diff --git a/apps/docs/public/static/search/connect-account-menu.png b/apps/docs/public/static/search/connect-account-menu.png deleted file mode 100644 index 4147656f006..00000000000 Binary files a/apps/docs/public/static/search/connect-account-menu.png and /dev/null differ diff --git a/apps/sim/app/api/organizations/[id]/chats/[chatId]/actions/receipts/route.ts b/apps/sim/app/api/organizations/[id]/chats/[chatId]/actions/receipts/route.ts new file mode 100644 index 00000000000..de2f563799e --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/chats/[chatId]/actions/receipts/route.ts @@ -0,0 +1,23 @@ +import { listAssistantActionReceiptsContract } from '@/lib/api/contracts/assistant-actions' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { assistantActionOperations } from '@/lib/assistant-actions/application/operations' +import { listOrganizationAssistantActionReceipts } from '@/lib/assistant-actions/application/receipts' + +export const GET = defineInternalJsonRoute({ + contract: listAssistantActionReceiptsContract, + auth: internalSessionAuth, + operation: assistantActionOperations.receipts, + rateLimit: internalRateLimits.user({ + bucketName: 'assistant-action-receipts', + config: { maxTokens: 120, refillRate: 120, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, chatId: params.chatId, ...query }), + useCase: listOrganizationAssistantActionReceipts, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx index 50673e47748..10b68ba5a24 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx @@ -1,10 +1,10 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { toast } from '@sim/emcn' import { toRecord } from '@sim/utils/object' import { useQueryClient } from '@tanstack/react-query' -import { useQueryStates } from 'nuqs' +import { createSerializer, useQueryStates } from 'nuqs' import { AssistantActionReviewProvider } from '@/components/assistant-actions' import { requestJson } from '@/lib/api/client/request' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' @@ -16,6 +16,7 @@ import { getMothershipAttachmentUrl, } from '@/lib/mothership/chat/attachment-preview' import { createSearchResource } from '@/lib/mothership/resources/search' +import { organizationRoutes } from '@/lib/navigation/paths' import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing' import { Composer } from '@/app/o/[organizationId]/home/components/composer' import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started' @@ -41,6 +42,7 @@ import { import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry' import { fetchWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { + resourceParam, searchFilterParsers, searchFiltersFromParams, } from '@/app/workspace/[workspaceId]/home/search-params' @@ -57,10 +59,26 @@ import { useHydrated } from '@/hooks/use-hydrated' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import type { ChatContext } from '@/stores/panel' +export interface OrganizationSummaryRequest { + id: string + message: string + assistantSearch: WorkspaceSearchFilters +} + +const serializeSearchConversation = createSerializer({ + ...organizationSearchParsers, + ...searchFilterParsers, + [resourceParam.key]: resourceParam.parser, +}) + interface OrganizationConversationProps { + active?: boolean + summaryRequest?: OrganizationSummaryRequest + onSummaryConsumed?: (id: string) => void userName?: string chatId?: string requestMode: ChatRequestMode + header?: ReactNode } /** Shared conversation surface; the destination fixes its intent before the first turn. */ @@ -74,7 +92,8 @@ export function OrganizationConversation(props: OrganizationConversationProps) { (props.requestMode === 'assistant' ? !searchAccess.memberScoped : !canBuild) ) return null - if (isRestoredChatEntry || !isClient || !session?.user?.id) return + if (isRestoredChatEntry || !isClient || !session?.user?.id) + return return ( + serializeSearchConversation(organizationRoutes(organization.id).searchChat(id), { + ...searchParams, + mode: active ? 'assistant' : 'search', + resource: controller.activeResourceState[0], + }) + : undefined, onResourceEvent: controller.onResourceEvent, activeResourceState: controller.activeResourceState, }) @@ -240,9 +273,21 @@ function OrganizationConversationContent({ const firstName = userName?.split(' ')[0] ?? '' useEffect(() => { - if (chat.resolvedChatId && !chat.isSending && !chat.isReconnecting) + if (active && chat.resolvedChatId && !chat.isSending && !chat.isReconnecting) markRead(chat.resolvedChatId) - }, [chat.resolvedChatId, chat.isSending, chat.isReconnecting, markRead]) + }, [active, chat.resolvedChatId, chat.isSending, chat.isReconnecting, markRead]) + + const consumedSummary = useRef(null) + useEffect(() => { + if (!active || !summaryRequest || consumedSummary.current === summaryRequest.id) return + consumedSummary.current = summaryRequest.id + onSummaryConsumed?.(summaryRequest.id) + void sendMessage(summaryRequest.message, undefined, undefined, { + requestMode: 'assistant', + assistantSearchLevel, + assistantSearch: summaryRequest.assistantSearch, + }) + }, [active, summaryRequest, onSummaryConsumed, sendMessage]) useEffect(() => { if (chatId) return @@ -370,6 +415,7 @@ function OrganizationConversationContent({ enabled={requestMode === 'assistant'} > addResource({ type: 'sources', @@ -428,6 +474,7 @@ function OrganizationConversationContent({ ) return ( void + connectLabel: 'Connect' | 'Add account' + onReconnect: () => void + canReconnect: boolean + canConnect: boolean + connectionPending: boolean +} + +/** Header actions act on the selected account; adding a connection uses its available slot. */ +export function AccountConnectionActions({ + organizationId, + integrationName, + account, + onConnect, + connectLabel, + onReconnect, + canReconnect, + canConnect, + connectionPending, +}: AccountConnectionActionsProps) { + const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const [confirming, setConfirming] = useState(false) + const pending = connectionPending || disconnect.isPending + return ( + <> + {account && ( + + Reconnect + + )} + {onConnect && ( + + {connectLabel} + + )} + {account && ( + <> + { + disconnect.reset() + setConfirming(true) + }} + > + Disconnect + + { + if (!disconnect.isPending) setConfirming(open) + }} + title={`Disconnect ${integrationName}`} + text={`Disconnect ${account.displayName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`} + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting', + pending: disconnect.isPending, + disabled: disconnect.isPending, + onClick: () => + disconnect.mutate(account.credentialId, { onSuccess: () => setConfirming(false) }), + }} + > + {disconnect.error?.message} + + + )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx index 792bc279324..e8d86c823a0 100644 --- a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx @@ -2,13 +2,14 @@ import { Chip, ChipInput, ChipLink, ChipSelect, ChipSwitch, Tooltip, toast } from '@sim/emcn' import { ArrowLeft, Search } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' import type { AssistantToolPermissionsResponse, UpdateAssistantToolPermissionsBody, } from '@/lib/api/contracts/assistant-actions' import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' import { organizationRoutes } from '@/lib/navigation/paths' -import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page' +import { integrationViewParsers } from '@/app/o/[organizationId]/integrations/search-params' import { CredentialDetailHeading, CredentialDetailLayout, @@ -23,6 +24,7 @@ import { useAssistantToolPermissions, useUpdateAssistantToolPermissions, } from '@/hooks/queries/assistant-actions' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' const POLICY_OPTIONS = [ { value: 'allow', label: 'Allow' }, @@ -43,9 +45,38 @@ export function AccountToolPermissions({ organizationId, credentialId, }: AccountToolPermissionsProps) { + return ( + + Integrations + + } + > + + + ) +} + +interface AccountToolPermissionsContentProps extends AccountToolPermissionsProps { + showHeading?: boolean +} + +export function AccountToolPermissionsContent({ + organizationId, + credentialId, + showHeading = false, +}: AccountToolPermissionsContentProps) { const query = useAssistantToolPermissions(organizationId, credentialId) const update = useUpdateAssistantToolPermissions(organizationId, credentialId) - const { search, setSearch } = useOrganizationPageFilters() + const [{ tools: search }, setParams] = useQueryStates(integrationViewParsers) + const setSearch = useDebouncedSearchSetter((value, options) => + setParams({ tools: value }, options) + ) const data = query.data const display = data ? resolveCredentialDisplay({ ...data.account, type: 'managed_oauth' }) @@ -59,13 +90,7 @@ export function AccountToolPermissions({ } return ( - - Integrations - - } - > + <> {query.error ? ( Loading tool permissions ) : (
-
- - ) : undefined - } - /> - {!data.canAutoApprove && ( -

- Your organization requires approval before tools run. -

- )} - {!data.account.active && ( -

- This account is disconnected or paused. Reconnect it before allowing tools - automatically. -

- )} -
+ {(showHeading || !data.canAutoApprove || !data.account.active) && ( +
+ {showHeading && ( + + ) : undefined + } + /> + )} + {!data.canAutoApprove && ( +

+ Your organization requires approval before tools run. +

+ )} + {!data.account.active && ( +

+ This account is disconnected or paused. Reconnect it before allowing tools + automatically. +

+ )} +
+ )} {data.tools.length > 0 && ( )} - + ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx new file mode 100644 index 00000000000..b7906e5cfb8 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx @@ -0,0 +1,126 @@ +'use client' + +import type { ReactNode } from 'react' +import { toast } from '@sim/emcn' +import { AccountConnectionActions } from '@/app/o/[organizationId]/integrations/account-connection-actions' +import { AccountToolPermissionsContent } from '@/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions' +import type { MemberIntegration } from '@/app/o/[organizationId]/integrations/utils' +import { + CredentialDetailHeading, + CredentialDetailLayout, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + useConnectOrganizationAccount, + useReconnectPersonalOrganizationAccount, +} from '@/hooks/queries/organization-accounts' + +interface IntegrationDetailProps { + organizationId: string + integration: MemberIntegration + accountId: string | null + accountHref: (accountId: string) => string + back: ReactNode + children?: ReactNode +} + +/** The selected account owns every permission and connection action in this view. */ +export function IntegrationDetail({ + organizationId, + integration, + accountId, + accountHref, + back, + children, +}: IntegrationDetailProps) { + const connect = useConnectOrganizationAccount() + const reconnect = useReconnectPersonalOrganizationAccount() + const onError = (error: Error) => toast.error(error.message) + const selected = accountId + ? integration.accounts.find((account) => account.credentialId === accountId) + : integration.accounts.length === 1 + ? integration.accounts[0] + : undefined + const target = integration.connectionTarget + return ( + connect.mutate({ organizationId, ...target }, { onError }) : undefined + } + onReconnect={() => { + if (selected) reconnect.mutate(selected.credentialId, { onError }) + }} + connectionPending={connect.isPending || reconnect.isPending} + canConnect={integration.ready} + canReconnect={Boolean( + selected && integration.reconnectAccountIds.includes(selected.credentialId) + )} + /> + ) + } + > + {children} + } + /> + {integration.managed ? ( + Organization managed + ) : ( + + {accountId && !selected ? ( + + This account is no longer connected. + + ) : selected ? ( + + {selected.displayName} + + ) : integration.accounts.length ? ( +
+ {integration.accounts.map((account) => ( + + ))} +
+ ) : ( + No account connected + )} +
+ )} + {selected && integration.permissionAccountIds.includes(selected.credentialId) && ( + + )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index 0869432d66d..b85c2901409 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -2,8 +2,7 @@ import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' -import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' +import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page' import { LiveMemberIntegrations } from '@/app/o/[organizationId]/integrations/live-member-integrations' import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' @@ -27,24 +26,24 @@ export function OrganizationIntegrations({ const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) return ( - ) } - > - {connectionRequest && ( - - )} - - + intro={ + connectionRequest && ( + + ) + } + /> ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx deleted file mode 100644 index 89f76f6ebaa..00000000000 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/** @vitest-environment jsdom */ -import { act } from 'react' -import { - organizationAccountsQueriesMock, - organizationAccountsQueriesMockFns, -} from '@sim/testing/mocks/organization-accounts-queries.mock' -import { - organizationProviderMock, - organizationProviderMockFns, -} from '@sim/testing/mocks/organization-provider.mock' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' -import { LiveMemberIntegrations } from '@/app/o/[organizationId]/integrations/live-member-integrations' - -const mocks = vi.hoisted(() => ({ connect: vi.fn() })) -vi.mock('@/hooks/queries/organization-accounts', () => organizationAccountsQueriesMock) -vi.mock('@/hooks/queries/organization-secrets', () => ({ - useOrganizationSecretSource: () => ({ data: { source: null } }), -})) -vi.mock('@/hooks/queries/search-integrations', () => ({ - useSearchIntegrations: () => ({ - data: [{ connectorType: 'coda', approved: true }], - isSuccess: true, - }), -})) -vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock) -vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ - IntegrationTile: () => null, -})) - -let root: Root -let container: HTMLDivElement -beforeEach(() => { - organizationAccountsQueriesMockFns.mockUseConnectOrganizationAccount.mockReturnValue({ - mutate: mocks.connect, - }) - organizationProviderMockFns.mockUseOrganizationContext.mockReturnValue({ - organization: { id: 'org', name: 'Example Organization' }, - }) - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) -afterEach(() => { - act(() => root.unmount()) - container.remove() -}) -it('never presents another MCP provider as a Coda connection', async () => { - const inventory = { - credentialGroup: { - id: 'group', - workspaceId: null, - organizationId: 'org', - name: 'Organization accounts', - description: null, - status: 'active', - options: [], - mcpServers: [ - { - id: 'coda-server', - name: 'Coda', - description: null, - authType: 'oauth', - managedConnectorId: 'coda', - enabled: true, - }, - ], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, - availableProviders: [], - assistantProviders: [], - availableMcpConnectors: ['coda'], - viewerAccounts: [], - viewerMcpAccounts: [ - { - credentialId: 'other', - mcpServerId: 'other-server', - displayName: 'Other private account', - status: 'active', - }, - ], - canManage: false, - indexingAvailable: true, - } satisfies OrganizationAccountsSettings - organizationAccountsQueriesMockFns.mockUseOrganizationAccounts.mockReturnValue({ - data: inventory, - }) - await act(async () => root.render()) - expect(container.textContent).not.toContain('Other private account') - const menu = container.querySelector('[aria-label="Coda integration actions"]') - if (!menu) throw new Error('Missing Coda actions menu') - await act(async () => - menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) - ) - const connect = [...document.querySelectorAll('[role="menuitem"]')].find( - (item) => item.textContent === 'Connect' - ) - if (!connect) throw new Error('Missing Coda connection control') - await act(async () => connect.click()) - expect(mocks.connect).toHaveBeenCalledWith( - { organizationId: 'org', mcpServerId: 'coda-server' }, - expect.any(Object) - ) -}) diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx index 6297deb3a6c..d8fc4bdf7f7 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -1,267 +1,209 @@ 'use client' -import { toast } from '@sim/emcn' -import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' -import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes' -import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' +import type { ReactNode } from 'react' +import { ChipLink } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { organizationRoutes } from '@/lib/navigation/paths' +import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' +import { IntegrationDetail } from '@/app/o/[organizationId]/integrations/integration-detail' import { - LIVE_SEARCH_SOURCE_TYPES, - liveSearchMcpConnector, - liveSearchMemberAccountProvider, -} from '@/lib/sim-search/live/source-catalog' -import { AccountActionsMenu } from '@/app/o/[organizationId]/integrations/account-actions-menu' -import { AssistantAccountIntegrations } from '@/app/o/[organizationId]/integrations/assistant-account-integrations' -import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils' -import { GenericSecretSourceRow } from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source' + integrationViewParsers, + serializeIntegrationView, +} from '@/app/o/[organizationId]/integrations/search-params' +import { + memberIntegrationEntries, + memberIntegrationStatus, +} from '@/app/o/[organizationId]/integrations/utils' +import { + GenericSecretSourceIcon, + GenericSecretSourceRow, +} from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source' +import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { - useConnectOrganizationAccount, - useOrganizationAccounts, - useReconnectPersonalOrganizationAccount, -} from '@/hooks/queries/organization-accounts' + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' import { useOrganizationSecretSource } from '@/hooks/queries/organization-secrets' import { useSearchIntegrations } from '@/hooks/queries/search-integrations' interface LiveMemberIntegrationsProps { organizationId: string search: string + navigationSearch: string + intro?: ReactNode + action?: ReactNode } -/** Personal OAuth apps and administrator-managed GitLab have distinct connection states. */ -export function LiveMemberIntegrations({ organizationId, search }: LiveMemberIntegrationsProps) { - const connect = useConnectOrganizationAccount() - const reconnect = useReconnectPersonalOrganizationAccount() - const onError = (error: Error) => toast.error(error.message) +/** Member integrations share one inventory and the platform's list/detail layout. */ +export function LiveMemberIntegrations({ + organizationId, + search, + navigationSearch, + intro, + action, +}: LiveMemberIntegrationsProps) { + const [{ integration, account }] = useQueryStates(integrationViewParsers) const inventory = useOrganizationAccounts(organizationId) const policies = useSearchIntegrations(organizationId) const secrets = useOrganizationSecretSource(organizationId) - if (inventory.error) - return ( - void inventory.refetch()} - variant='inline' - /> - ) - if (!inventory.data) - return Loading your connections - const data = inventory.data - const loadedPolicies = policies.isSuccess ? policies.data : undefined - const approvals = new Map(loadedPolicies?.map((policy) => [policy.connectorType, policy])) - const permissionAccountIds = (data.viewerAccounts ?? []) - .filter((account) => { - const provider = findCredentialGroupProviderFromProviderId(account.providerId) - return provider !== null && data.assistantProviders.includes(provider) + const routes = organizationRoutes(organizationId) + const href = (selected: string | null, selectedAccount: string | null = null) => + serializeIntegrationView(routes.integrations, { + q: navigationSearch, + integration: selected, + account: selectedAccount, }) - .map((account) => account.credentialId) - const group = data.credentialGroup - const mcpProviders = new Map( - group?.mcpServers.map((server) => [server.id, server.managedConnectorId]) + const entries = inventory.data + ? memberIntegrationEntries(inventory.data, policies.isSuccess ? policies.data : undefined) + : [] + const selected = entries.find((entry) => entry.id === integration) + const status = inventory.error ? ( + void inventory.refetch()} + variant='inline' + /> + ) : !inventory.data ? ( + Loading your connections + ) : null + const errors = ( + <> + {policies.error && ( + void policies.refetch()} + variant='inline' + /> + )} + {secrets.error && ( + void secrets.refetch()} + variant='inline' + /> + )} + ) - const mcpAccounts = (provider: string) => - (data.viewerMcpAccounts ?? []).filter( - (account) => mcpProviders.get(account.mcpServerId) === provider + if (integration) { + const back = ( + + {account ? (selected?.name ?? 'Integration') : 'Integrations'} + ) - const accountsForProvider = (provider: string) => { - if (liveSearchMcpConnector(provider)) return mcpAccounts(provider) - const memberProvider = - provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) - return (data.viewerAccounts ?? []).filter( - (account) => findCredentialGroupProviderFromProviderId(account.providerId) === memberProvider + if (selected && !status) { + return ( + href(selected.id, credentialId)} + back={back} + > + {errors} + + ) + } + return ( + + {status ?? ( + <> + {errors} + {integration === 'secrets' ? ( + secrets.data?.source ? ( + + ) : ( + + {secrets.isPending + ? 'Loading connection' + : 'This integration is no longer available.'} + + ) + ) : ( + + {policies.isPending + ? 'Loading connection' + : 'This integration is no longer available.'} + + )} + + )} + ) } - const available = LIVE_SEARCH_SOURCE_TYPES.filter( - ([provider]) => - (approvals.get(provider)?.available !== false || accountsForProvider(provider).length > 0) && - LIVE_SEARCH_SCOPE_FIELDS[provider] && - ((provider !== 'hubspot' && provider !== 'zoom') || - data.availableMcpConnectors.includes(provider) || - mcpAccounts(provider).length > 0) && - (approvals.get(provider)?.approved || accountsForProvider(provider).length > 0) - ) const query = search.trim().toLowerCase() - const visible = available.filter(([provider, meta]) => - `${provider} ${meta.name} ${accountsForProvider(provider) - .map((account) => account.displayName) - .join(' ')}` + const visible = entries.filter((entry) => + [entry.provider, entry.name, ...entry.accounts.map((item) => item.displayName)] + .join(' ') .toLowerCase() .includes(query) ) - const secretSource = secrets.data?.source - const showSecrets = secretSource && 'generic secrets'.includes(query) + const showSecrets = secrets.data?.source && 'generic secrets'.includes(query) return ( -
- -
- {policies.error && ( - void policies.refetch()} - variant='inline' - /> - )} - {secrets.error && ( - void secrets.refetch()} - variant='inline' - /> - )} - {showSecrets && ( - - )} - {visible.map(([provider, meta]) => { - const approval = approvals.get(provider) - const approved = approval?.approved === true - const name = meta.name - if (provider === 'gitlab') - return ( - } - title={name} - description='Access follows project permissions' - trailing={ - Organization managed - } - /> - ) - const memberProvider = - provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) - const accounts = accountsForProvider(provider) - const options = - group?.options.filter( - (option) => - option.provider === memberProvider && - option.status === 'active' && - option.configurationStatus === 'ready' && - (provider !== 'slack' || - (option.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) - ) ?? [] - const servers = liveSearchMcpConnector(provider) - ? (group?.mcpServers.filter( - (server) => server.managedConnectorId === provider && server.enabled - ) ?? []) - : [] - const option = options.find( - (option) => - !accounts.some((account) => 'optionId' in account && account.optionId === option.id) - ) - const server = servers.find( - (server) => - !accounts.some( - (account) => 'mcpServerId' in account && account.mcpServerId === server.id - ) - ) - const connectionTarget = server - ? { mcpServerId: server.id } - : option - ? { optionId: option.id } - : undefined - const ready = - approval?.available !== false && - group?.status === 'active' && - (options.length > 0 || servers.length > 0) && - approved && - ((provider !== 'hubspot' && provider !== 'zoom') || - data.availableMcpConnectors.includes(provider)) - const reconnectAccountIds = ready - ? accounts - .filter((account) => - 'optionId' in account - ? options.some((option) => option.id === account.optionId) - : servers.some((server) => server.id === account.mcpServerId) - ) - .map((account) => account.credentialId) - : [] - const scope = - approval?.policy?.accessMode === 'service_account' - ? 'Selected resources you can access' - : 'All accessible content' - const state = !loadedPolicies - ? undefined - : approval?.available === false - ? 'Currently unavailable' - : !approved - ? 'Disabled by your organization' - : group && group.status !== 'active' - ? 'Connections are paused by your organization' - : !ready - ? 'Not configured' - : accounts.length - ? scope - : undefined - const description = [ - accounts - .map( - (account) => - `${account.displayName}${account.status === 'needs_reauth' ? ' · Reconnect needed' : ''}` - ) - .join(', '), - state, - ] - .filter(Boolean) - .join(' · ') - return ( + + {intro} + {status ?? ( +
+ {errors} + {showSecrets && ( } - title={name} - description={description} - trailing={ - connect.mutate({ organizationId, ...connectionTarget }, { onError }) - : undefined - } - onReconnect={(credentialId) => reconnect.mutate(credentialId, { onError })} - connectionPending={connect.isPending || reconnect.isPending} - connecting={ - connect.isPending && - (('optionId' in connect.variables && - connect.variables.optionId === option?.id) || - ('mcpServerId' in connect.variables && - connect.variables.mcpServerId === server?.id)) - } - connectionReady={ready} - reconnectAccountIds={reconnectAccountIds} - permissionAccountIds={permissionAccountIds} - /> + icon={} + title='Generic Secrets' + description={ + secrets.data?.source?.mode === 'organization' + ? 'Organization managed' + : 'Personal secrets' } + href={ + secrets.data?.source?.mode === 'member' + ? serializeIntegrationView(routes.memberSecrets, { q: navigationSearch }) + : href('secrets') + } + clickLabel='Open Generic Secrets integration' + navigable + /> + )} + {visible.map((entry) => ( + } + title={entry.name} + description={memberIntegrationStatus(entry)} + href={href(entry.id)} + clickLabel={`Open ${entry.name} integration`} + navigable /> - ) - })} - {!visible.length && - !showSecrets && - !assistantAccountOptions(data, search).visible.length && - loadedPolicies && - secrets.data && - query && ( - No matching integrations + ))} + {!visible.length && !showSecrets && policies.isSuccess && secrets.isSuccess && ( + + {query ? 'No matching integrations' : 'No integrations available'} + )} -
-
+
+ )} + ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/integrations/search-params.ts index ea4bdcb4477..b1d784d7f7d 100644 --- a/apps/sim/app/o/[organizationId]/integrations/search-params.ts +++ b/apps/sim/app/o/[organizationId]/integrations/search-params.ts @@ -1,4 +1,16 @@ -import { createLoader, parseAsString } from 'nuqs/server' +import { createLoader, createSerializer, parseAsString } from 'nuqs/server' +import { organizationPageParsers } from '@/app/o/[organizationId]/components/organization-page/search-params' + +export const integrationViewParsers = { + integration: parseAsString, + account: parseAsString, + tools: parseAsString.withDefault(''), +} as const + +export const serializeIntegrationView = createSerializer({ + ...organizationPageParsers, + ...integrationViewParsers, +}) export const integrationConnectionParams = { connectorType: parseAsString.withDefault(''), diff --git a/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx b/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx index d7801951208..083ca5c6423 100644 --- a/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/secrets/page.tsx @@ -1,13 +1,27 @@ import type { Metadata } from 'next' +import { createLoader, type SearchParams } from 'nuqs/server' import { OrganizationSecretsPage } from '@/components/secrets/organization-secrets-page' +import { organizationRoutes } from '@/lib/navigation/paths' +import { organizationPageParsers } from '@/app/o/[organizationId]/components/organization-page/search-params' +import { serializeIntegrationView } from '@/app/o/[organizationId]/integrations/search-params' export const metadata: Metadata = { title: 'Generic Secrets' } interface MemberSecretsPageProps { params: Promise<{ organizationId: string }> + searchParams: Promise } -export default async function Page({ params }: MemberSecretsPageProps) { +const loadSearch = createLoader(organizationPageParsers) + +export default async function Page({ params, searchParams }: MemberSecretsPageProps) { const { organizationId } = await params - return + const { q } = await loadSearch(searchParams) + return ( + + ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/utils.ts b/apps/sim/app/o/[organizationId]/integrations/utils.ts index 061bf684808..e00352082a5 100644 --- a/apps/sim/app/o/[organizationId]/integrations/utils.ts +++ b/apps/sim/app/o/[organizationId]/integrations/utils.ts @@ -1,7 +1,17 @@ +import type { ComponentType } from 'react' +import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations' import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers' +import { + findCredentialGroupProviderFromProviderId, + getCredentialGroupProviderId, + getCredentialGroupProviderService, +} from '@/lib/credential-groups/providers' +import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes' +import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' import { LIVE_SEARCH_SOURCE_TYPES, + liveSearchMcpConnector, liveSearchMemberAccountProvider, } from '@/lib/sim-search/live/source-catalog' @@ -17,13 +27,9 @@ export function assistantAccountOptions(data: OrganizationAccountsSettings, sear const providers = data.assistantProviders.filter( (provider) => !SEARCH_ACCOUNT_PROVIDERS.has(provider) ) - const ownedOptions = new Set(data.viewerAccounts?.map((account) => account.optionId)) const configured = data.credentialGroup?.options.filter( - (option) => - !SEARCH_ACCOUNT_PROVIDERS.has(option.provider) && - ((option.status === 'active' && providers.includes(option.provider)) || - ownedOptions.has(option.id)) + (option) => !SEARCH_ACCOUNT_PROVIDERS.has(option.provider) ) ?? [] const query = search.trim().toLowerCase() const visible = configured.filter((option) => @@ -39,3 +45,205 @@ export function assistantAccountOptions(data: OrganizationAccountsSettings, sear ) return { providers, configured, visible } } + +export interface MemberIntegration { + id: string + provider: string + name: string + icon: ComponentType<{ className?: string }> + accounts: MemberIntegrationAccount[] + permissionAccountIds: string[] + reconnectAccountIds: string[] + connectionTarget?: { optionId: string } | { mcpServerId: string } + ready: boolean + managed?: boolean + description?: string +} + +type MemberIntegrationAccount = + | NonNullable[number] + | NonNullable[number] + +/** One member inventory, preserving native grant and managed MCP server identities. */ +export function memberIntegrationEntries( + data: OrganizationAccountsSettings, + policies: SearchIntegrationStatus[] | undefined +): MemberIntegration[] { + const group = data.credentialGroup + const approvals = new Map(policies?.map((policy) => [policy.connectorType, policy])) + const permissionIds = new Set( + (data.viewerAccounts ?? []) + .filter((account) => { + const provider = findCredentialGroupProviderFromProviderId(account.providerId) + return provider !== null && data.assistantProviders.includes(provider) + }) + .map((account) => account.credentialId) + ) + const mcpProviders = new Map( + group?.mcpServers.map((server) => [server.id, server.managedConnectorId]) + ) + const entries: MemberIntegration[] = [] + for (const [provider, meta] of LIVE_SEARCH_SOURCE_TYPES) { + const memberProvider = + provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) + const mcp = liveSearchMcpConnector(provider) + const accounts: MemberIntegrationAccount[] = mcp + ? (data.viewerMcpAccounts ?? []).filter( + (account) => mcpProviders.get(account.mcpServerId) === provider + ) + : (data.viewerAccounts ?? []).filter( + (account) => + findCredentialGroupProviderFromProviderId(account.providerId) === memberProvider + ) + const approval = approvals.get(provider) + const available = + (provider !== 'hubspot' && provider !== 'zoom') || + data.availableMcpConnectors.includes(provider) + const configured = + group?.options.some((option) => option.provider === memberProvider) || + group?.mcpServers.some((server) => server.managedConnectorId === provider) + if ( + !LIVE_SEARCH_SCOPE_FIELDS[provider] || + (!accounts.length && + !configured && + (approval?.available === false || !available || !approval?.approved)) + ) + continue + const options = + group?.options.filter( + (option) => + option.provider === memberProvider && + option.status === 'active' && + option.configurationStatus === 'ready' && + (provider !== 'slack' || + (option.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) + ) ?? [] + const servers = mcp + ? (group?.mcpServers.filter( + (server) => server.managedConnectorId === provider && server.enabled + ) ?? []) + : [] + const option = options.find( + (candidate) => + !accounts.some((account) => 'optionId' in account && account.optionId === candidate.id) + ) + const server = servers.find( + (candidate) => + !accounts.some( + (account) => 'mcpServerId' in account && account.mcpServerId === candidate.id + ) + ) + const ready = + (mcp + ? available + : memberProvider !== null && data.availableProviders.includes(memberProvider)) && + approval?.available !== false && + group?.status === 'active' && + (options.length > 0 || servers.length > 0) && + approval?.approved === true && + available + const description = + provider === 'gitlab' + ? 'Access follows project permissions' + : !policies + ? undefined + : approval?.available === false + ? 'Currently unavailable' + : !approval?.approved + ? 'Disabled by your organization' + : group && group.status !== 'active' + ? 'Connections are paused by your organization' + : !ready + ? 'Not configured' + : approval.policy?.accessMode === 'service_account' + ? 'Selected resources you can access' + : 'All accessible content' + entries.push({ + id: `source:${provider}`, + provider, + name: meta.name, + icon: meta.icon, + accounts, + ready, + managed: provider === 'gitlab', + description, + permissionAccountIds: accounts + .filter((account) => permissionIds.has(account.credentialId)) + .map((account) => account.credentialId), + reconnectAccountIds: ready + ? accounts + .filter((account) => + 'optionId' in account + ? options.some((candidate) => candidate.id === account.optionId) + : servers.some((candidate) => candidate.id === account.mcpServerId) + ) + .map((account) => account.credentialId) + : [], + connectionTarget: server + ? { mcpServerId: server.id } + : option + ? { optionId: option.id } + : undefined, + }) + } + const { providers, configured } = assistantAccountOptions(data, '') + for (const provider of new Set(configured.map((option) => option.provider))) { + const options = configured.filter((option) => option.provider === provider) + const accounts = (data.viewerAccounts ?? []).filter((account) => + options.some((option) => option.id === account.optionId) + ) + const source = assistantSearchSourceForProvider(getCredentialGroupProviderId(provider)) + const policy = source ? approvals.get(source) : undefined + const readyOptions = options.filter( + (option) => + group?.status === 'active' && + option.status === 'active' && + option.configurationStatus === 'ready' && + providers.includes(provider) && + data.availableProviders.includes(provider) && + (!source || (policy?.approved === true && policy.available !== false)) + ) + const target = readyOptions.find( + (option) => !accounts.some((account) => account.optionId === option.id) + ) + const service = getCredentialGroupProviderService(provider) + entries.push({ + id: `app:${provider}`, + provider, + name: service.name, + icon: service.icon, + accounts, + ready: readyOptions.length > 0, + description: + source && !policies + ? undefined + : readyOptions.length + ? undefined + : 'Connections unavailable', + connectionTarget: target ? { optionId: target.id } : undefined, + permissionAccountIds: accounts + .filter((account) => permissionIds.has(account.credentialId)) + .map((account) => account.credentialId), + reconnectAccountIds: accounts + .filter((account) => readyOptions.some((option) => option.id === account.optionId)) + .map((account) => account.credentialId), + }) + } + return entries.sort((left, right) => left.name.localeCompare(right.name)) +} + +export function memberIntegrationStatus(entry: MemberIntegration) { + if (entry.managed) return 'Organization managed' + if (!entry.ready) return entry.description ?? 'Checking availability' + if ( + entry.accounts.some( + (account) => + account.status === 'needs_reauth' && + entry.reconnectAccountIds.includes(account.credentialId) + ) + ) + return 'Reconnect needed' + if (entry.accounts.length) + return entry.accounts.length === 1 ? '1 account' : `${entry.accounts.length} accounts` + return 'Not connected' +} diff --git a/apps/sim/app/o/[organizationId]/search/search-params.ts b/apps/sim/app/o/[organizationId]/search/search-params.ts index b0bc0b3c71a..40faaad7f1f 100644 --- a/apps/sim/app/o/[organizationId]/search/search-params.ts +++ b/apps/sim/app/o/[organizationId]/search/search-params.ts @@ -2,7 +2,7 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' export const organizationSearchParsers = { q: parseAsString.withDefault(''), - mode: parseAsStringLiteral(['search', 'assistant']).withDefault('search'), + mode: parseAsStringLiteral(['search', 'assistant']), } as const /** A query is a filter-like view change, not navigation: replace, and clear when empty. */ diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 90b9bf6dc1c..6eb1813c337 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -1,16 +1,17 @@ 'use client' -import { lazy, Suspense, useEffect, useRef } from 'react' -import { Chip, ChipSwitch, ComposerActionButton } from '@sim/emcn' +import { lazy, Suspense, useCallback, useEffect, useRef, useState, useTransition } from 'react' +import { Chip, ComposerActionButton, cn } from '@sim/emcn' import { ArrowUp, Plus, Search, Sparkles, StopFilled } from '@sim/emcn/icons' +import { generateId } from '@sim/utils/id' import { useIsFetching, useQueryClient } from '@tanstack/react-query' import { usePathname, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { HEADER_ACTION_CLUSTER } from '@/components/page-header-bar' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import { resourceScopeKey } from '@/lib/core/resource-scope' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { organizationRoutes } from '@/lib/navigation/paths' +import type { OrganizationSummaryRequest } from '@/app/o/[organizationId]/components/organization-conversation' import { SearchInputBar } from '@/app/o/[organizationId]/components/search-input-bar' import { SearchLandingHistory } from '@/app/o/[organizationId]/components/search-landing-history' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' @@ -20,6 +21,7 @@ import { } from '@/app/o/[organizationId]/search/search-params' import { SearchResultsView } from '@/app/o/[organizationId]/search/search-results-view' import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context' +import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button' import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help' import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' @@ -167,24 +169,43 @@ function OrganizationSearchContent({ userId, chatId }: OrganizationSearchProps) organizationSearchUrlKeys ) const activeMode = - mothershipAvailable && (hasConversation || mode === 'assistant') ? 'assistant' : 'search' + mothershipAvailable && (mode ?? (hasConversation ? 'assistant' : 'search')) === 'assistant' + ? 'assistant' + : 'search' + const [assistantOpened, setAssistantOpened] = useState(activeMode === 'assistant') + const [conversationInstance, setConversationInstance] = useState(0) + const [isStartingNew, startNewTransition] = useTransition() + const [summaries, setSummaries] = useState([]) + const consumeSummary = useCallback( + (id: string) => setSummaries((current) => current.filter((request) => request.id !== id)), + [] + ) const changeMode = (next: 'search' | 'assistant') => { - if (hasConversation) { - const params = new URLSearchParams() - if (next === 'assistant') params.set('mode', 'assistant') - if (q) params.set('q', q) - router.push(`${routes.search}${params.size ? `?${params}` : ''}`) + if (next === 'assistant') setAssistantOpened(true) + void setParams({ mode: next }, { history: 'push' }) + } + const startNew = () => { + setSummaries([]) + const { clearDraft } = useMothershipDraftsStore.getState() + const scope = `${userId}:organization:${organization.id}` + if (activeMode === 'assistant') { + clearDraft(`${scope}:search-assistant`) } else { - void setParams({ mode: next }) + clearDraft(`${scope}:search`) + clearDraft(`${scope}:search:query:`) } + startNewTransition(() => { + setConversationInstance((current) => current + 1) + router.push(activeMode === 'assistant' ? `${routes.search}?mode=assistant` : routes.search, { + scroll: false, + }) + }) } const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => { - const stored = MothershipHandoffStorage.store( - { message, requestMode: 'assistant', assistantSearch }, - { organizationId: organization.id } - ) - if (stored) void setParams({ mode: 'assistant' }) + setSummaries((current) => [...current, { id: generateId(), message, assistantSearch }]) + changeMode('assistant') } + const submit = (draft: string) => { const next = draft.trim() if (!next) return @@ -201,38 +222,79 @@ function OrganizationSearchContent({ userId, chatId }: OrganizationSearchProps) void setParams({ q: next }) } } + const newLabel = activeMode === 'assistant' ? 'New chat' : 'New search' + const header = ( + <> + + {(hasConversation || (activeMode === 'search' && q.trim())) && ( + + {newLabel} + New + + )} + + ) return ( -
-
-
- {mothershipAvailable ? ( - - ) : ( - Search +
+
+ {(assistantOpened || hasConversation || activeMode === 'assistant') && + mothershipAvailable && ( +
+ }> + + +
)} -
- {hasConversation && ( - changeMode('search')}> - New search - - )} -
-
- {activeMode === 'assistant' ? ( - }> - - - ) : ( - } - query={q.trim()} - onSummarize={mothershipAvailable ? summarize : undefined} - /> + {activeMode === 'search' && ( + <> +
+ {header} +
+
+ + } + query={q.trim()} + onSummarize={mothershipAvailable ? summarize : undefined} + /> +
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx index 29a40a09990..23ceefc8305 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx @@ -13,6 +13,7 @@ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/comp interface ChatPanelLayoutProps { children: ReactNode + header?: ReactNode panel: ReactNode collapsed: boolean label: string @@ -27,6 +28,7 @@ interface ChatPanelLayoutProps { /** Shared resize handle and collapse control for resources and Search results. */ export function ChatPanelLayout({ children, + header, panel, collapsed, label, @@ -48,7 +50,15 @@ export function ChatPanelLayout({ RESOURCE_HEADER_CLASSES.layout )} > -
{children}
+
+ {header &&
{header}
} + {children} +
{!collapsed && (
panel: ReturnType children: ReactNode + header?: ReactNode onSummarize?: (message: string, filters: WorkspaceSearchFilters) => void } @@ -33,6 +34,7 @@ export function ChatResourcePanel({ chat, panel, children, + header, onSummarize, }: ChatResourcePanelProps) { useBrowserTabResources(panel.desktopTabResourceOptions) @@ -71,6 +73,7 @@ export function ChatResourcePanel({ ) return ( + reviewedToolIds?: ReadonlySet /** Supplies tool behavior without coupling the group layout to the block registry. */ ToolCallComponent: ComponentType renderBrowserTakeover?: (reason: string) => ReactNode @@ -117,6 +119,8 @@ export function AgentGroupView({ ToolCallComponent, renderBrowserTakeover, liveIndicator, + standaloneToolIds, + reviewedToolIds, }: AgentGroupViewProps) { const AgentIcon = getAgentIcon(agentName) const isMainAgent = agentName === 'mothership' @@ -148,7 +152,7 @@ export function AgentGroupView({ const [manualExpanded, setManualExpanded] = useState(defaultExpanded) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) - const pendingInteraction = hasPendingInteraction(items) + const pendingInteraction = hasPendingInteraction(items, false, reviewedToolIds) /** Blocking interactions override manual collapse so the user can resume the turn. */ const expanded = pendingInteraction || @@ -161,12 +165,16 @@ export function AgentGroupView({ : getLaneLiveIndicator( isMainAgent ? { + standaloneToolIds, + reviewedToolIds, kind: 'main', parts: [items], isActive: isStreaming, isOpen: isStreaming && isLaneOpen, } : { + standaloneToolIds, + reviewedToolIds, kind: 'subagent', parts: [items], isActive: isStreaming && isWorking, @@ -246,6 +254,8 @@ export function AgentGroupView({ return ( @@ -13,11 +16,54 @@ function renderBrowserTakeover(reason: string) { /** Workspace adapter retains permission, handoff, and integration lookup behavior. */ export function AgentGroup(props: AgentGroupProps) { + const layout = useAssistantActionLayout() + const lifted: ToolCallData[] = [] + const items = liftNestedActions( + props.items, + layout.standaloneToolIds, + props.agentName !== 'mothership', + lifted + ) return ( - + <> + + {lifted.length > 0 && ( +
+ {lifted.map((tool) => ( + + ))} +
+ )} + ) } + +/** A nested approval or receipt stays outside its agent's collapsible history. */ +function liftNestedActions( + items: AgentGroupItem[], + ids: ReadonlySet, + includeOwn: boolean, + lifted: ToolCallData[] +): AgentGroupItem[] { + if (!ids.size) return items + return items.flatMap((item): AgentGroupItem[] => { + if (item.type === 'tool' && includeOwn && ids.has(item.data.id)) { + lifted.push(item.data) + return [] + } + if (item.type === 'agent_group') + return [ + { + ...item, + group: { ...item.group, items: liftNestedActions(item.group.items, ids, true, lifted) }, + }, + ] + return [item] + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts index 580318ef609..8b48a308a20 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts @@ -13,6 +13,42 @@ const liveId = (items: AgentGroupItem[], isOpen = true) => { } describe('getLaneLiveIndicator', () => { + it.each([false, true])( + 'leaves the turn indicator visible when a settled action is lifted (nested: %s)', + (nested) => { + const action = call('action', 'jira_write') + const items: AgentGroupItem[] = nested + ? [ + { + type: 'agent_group', + group: { + id: 'child', + agentName: 'research', + agentLabel: 'Research', + items: [action], + isDelegating: false, + isOpen: true, + }, + }, + ] + : [action] + const input = { + kind: 'subagent' as const, + parts: [items], + isActive: true, + isOpen: true, + standaloneToolIds: new Set(['action']), + } + expect(getLaneLiveIndicator(input)).toBeUndefined() + expect( + getLaneLiveIndicator({ + ...input, + parts: [[...items, { type: 'text', content: 'Checking the result.' }]], + }) + ).toEqual({ type: 'narration' }) + } + ) + it('gives the gap to a succeeded trailing call, including search, never a failure', () => { expect(liveId([call('s', 'search_workspace'), call('a', 'read')])).toBe('a') expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBe('s') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts index 1d40da46ba6..946322ab9b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts @@ -13,17 +13,22 @@ import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/me import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' /** Keep answers and interactions in the transcript, outside collapsible tool history. */ -function isStandaloneItem(item: AgentGroupItem): boolean { +function isStandaloneItem(item: AgentGroupItem, standaloneToolIds?: ReadonlySet): boolean { return ( item.type !== 'tool' || + standaloneToolIds?.has(item.data.id) || needsToolInput(item.data) || item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID ) } /** Interactions render as their own cards, so they never hold a lane's live indicator. */ -function canHoldIndicator(tool: ToolCallData): boolean { - return !needsToolInput(tool) && tool.toolName !== RETIRED_BROWSER_REQUEST_TAKEOVER_ID +function canHoldIndicator(tool: ToolCallData, standaloneToolIds?: ReadonlySet): boolean { + return ( + !standaloneToolIds?.has(tool.id) && + !needsToolInput(tool) && + tool.toolName !== RETIRED_BROWSER_REQUEST_TAKEOVER_ID + ) } /** A run of consecutive calls the main lane renders together, as one activity group. */ @@ -40,11 +45,14 @@ export type MainLaneEntry = * including search and document reads, and an interaction stands * on its own and closes the run before it. */ -export function splitMainLane(items: AgentGroupItem[]): MainLaneEntry[] { +export function splitMainLane( + items: AgentGroupItem[], + standaloneToolIds?: ReadonlySet +): MainLaneEntry[] { const entries: MainLaneEntry[] = [] let run: ActivityRun | undefined for (const [index, item] of items.entries()) { - if (item.type === 'tool' && !isStandaloneItem(item)) { + if (item.type === 'tool' && !isStandaloneItem(item, standaloneToolIds)) { if (!run) { run = { tools: [] } entries.push({ type: 'run', run }) @@ -91,22 +99,26 @@ export function getActiveBrowserTakeover(items: AgentGroupItem[]): ActiveBrowser */ export function hasPendingInteraction( items: AgentGroupItem[], - includeBrowserTakeover = false + includeBrowserTakeover = false, + reviewedToolIds?: ReadonlySet ): boolean { return ( (includeBrowserTakeover && getActiveBrowserTakeover(items) !== null) || items.some((item) => item.type === 'tool' - ? needsToolInput(item.data) + ? needsToolInput(item.data) && !reviewedToolIds?.has(item.data.id) : item.type === 'agent_group' && - hasPendingInteraction(item.group.items, includeBrowserTakeover) + hasPendingInteraction(item.group.items, includeBrowserTakeover, reviewedToolIds) ) ) } /** The lane, or a lane nested in it, is waiting on the user rather than on work. */ -function isLaneAwaitingUser(items: AgentGroupItem[]): boolean { - return hasPendingInteraction(items, true) +function isLaneAwaitingUser( + items: AgentGroupItem[], + reviewedToolIds?: ReadonlySet +): boolean { + return hasPendingInteraction(items, true, reviewedToolIds) } /** @@ -130,6 +142,8 @@ export function isLaneWorking( export type LaneLiveIndicator = { type: 'call'; tool: ToolCallData } | { type: 'narration' } export interface LaneActivityInput { + standaloneToolIds?: ReadonlySet + reviewedToolIds?: ReadonlySet kind: 'main' | 'subagent' /** The lane's items; the main lane passes one part per transcript segment, in order. */ parts: AgentGroupItem[][] @@ -140,11 +154,26 @@ export interface LaneActivityInput { } /** The latest call of the main lane's last run, which owns the trailing gap. */ -function getMainTrailingCall(items: AgentGroupItem[]): ToolCallData | undefined { - const last = splitMainLane(items).at(-1) +function getMainTrailingCall( + items: AgentGroupItem[], + standaloneToolIds?: ReadonlySet +): ToolCallData | undefined { + const last = splitMainLane(items, standaloneToolIds).at(-1) return last?.type === 'run' ? last.run.tools.at(-1) : undefined } +function hasVisibleLaneContent( + items: AgentGroupItem[], + standaloneToolIds?: ReadonlySet +): boolean { + return items.some((item) => { + if (item.type === 'tool') return !standaloneToolIds?.has(item.data.id) + if (item.type === 'agent_group') + return Boolean(item.group.error) || hasVisibleLaneContent(item.group.items, standaloneToolIds) + return hasAgentGroupItemContent(item) + }) +} + /** * The one live indicator of a lane, if it has one. See * {@link getTurnLiveIndicators} for the rule this implements. @@ -154,18 +183,24 @@ export function getLaneLiveIndicator({ parts, isActive, isOpen, + standaloneToolIds, + reviewedToolIds, }: LaneActivityInput): LaneLiveIndicator | undefined { - if (!isActive || parts.some(isLaneAwaitingUser)) return undefined - const calls = parts.flatMap(collectGroupTools).filter(canHoldIndicator) + if (!isActive || parts.some((items) => isLaneAwaitingUser(items, reviewedToolIds))) + return undefined + const calls = parts + .flatMap(collectGroupTools) + .filter((tool) => canHoldIndicator(tool, standaloneToolIds)) const running = getNewestRunningTool(calls) if (running) return { type: 'call', tool: running } if (!isOpen) return undefined if (kind === 'subagent' && calls.length === 0) { - return parts.some((items) => items.some(hasAgentGroupItemContent)) + return parts.some((items) => hasVisibleLaneContent(items, standaloneToolIds)) ? { type: 'narration' } : undefined } - const latest = kind === 'main' ? getMainTrailingCall(parts.at(-1) ?? []) : calls.at(-1) + const latest = + kind === 'main' ? getMainTrailingCall(parts.at(-1) ?? [], standaloneToolIds) : calls.at(-1) return latest?.status === ToolCallStatus.success ? { type: 'call', tool: latest } : undefined } @@ -217,12 +252,17 @@ export interface TurnLiveIndicators { * activity changes split it into segments, but it is one lane with one * indicator. */ -export function getTurnLiveIndicators(lanes: TurnLane[], isStreaming: boolean): TurnLiveIndicators { +export function getTurnLiveIndicators( + lanes: TurnLane[], + isStreaming: boolean, + layout: Pick = {} +): TurnLiveIndicators { const byLane = new Map() - const isAwaiting = lanes.some((lane) => isLaneAwaitingUser(lane.items)) + const isAwaiting = lanes.some((lane) => isLaneAwaitingUser(lane.items, layout.reviewedToolIds)) if (!isStreaming) return { isAwaitingUser: isAwaiting, byLane } const mainParts = lanes.filter((lane) => lane.agentName === 'mothership') const mainIndicator = getLaneLiveIndicator({ + ...layout, kind: 'main', parts: mainParts.map((lane) => lane.items), isActive: true, @@ -234,6 +274,7 @@ export function getTurnLiveIndicators(lanes: TurnLane[], isStreaming: boolean): lane.agentName === 'mothership' ? mainIndicator : getLaneLiveIndicator({ + ...layout, kind: 'subagent', parts: [lane.items], isActive: working, 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 f0d9da25cb3..e1972211631 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 @@ -8,6 +8,7 @@ import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' interface MainAgentActivityProps { + standaloneToolIds?: ReadonlySet activity?: ToolActivity items: AgentGroupItem[] ToolCallComponent: ComponentType @@ -31,8 +32,9 @@ export function MainAgentActivity({ renderItem, autoScrollActivity, liveToolId, + standaloneToolIds, }: MainAgentActivityProps) { - const entries = splitMainLane(items) + const entries = splitMainLane(items, standaloneToolIds) const runActivity = groupActivity && !isAgentGroupResolved(items) ? omit(groupActivity, ['completedTitle']) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 2790d36178f..9278ef7ff1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -188,5 +188,15 @@ export function ToolCallItem({ ), } - return renderStatus ? renderStatus(activity) : + const fallback = renderStatus ? renderStatus(activity) : + return toolCallId && getToolMetadata(toolName)?.assistant ? ( + + ) : ( + fallback + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 5be65281a6b..753becb41e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -13,6 +13,7 @@ import { import { cn } from '@sim/emcn' import { CircleStop } from '@sim/emcn/icons' import { isPlainRecord } from '@sim/utils/object' +import { useAssistantActionLayout } from '@/components/assistant-actions' import type { ToolActivity } from '@/lib/mothership/generated/protocol' import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1' import type { TaskBlockInfo } from '@/lib/mothership/request/types' @@ -895,11 +896,13 @@ export function assistantMessageHasRenderableContent( /** The turn's live indicators, decided once by {@link getTurnLiveIndicators} for every lane. */ function getMessageLiveIndicators( segments: MessageSegment[], - isStreaming: boolean + isStreaming: boolean, + actionLayout: ReturnType ): TurnLiveIndicators { return getTurnLiveIndicators( segments.flatMap((segment) => (segment.type === 'agent_group' ? [segment] : [])), - isStreaming + isStreaming, + actionLayout ) } @@ -1042,9 +1045,10 @@ function MessageContentInner({ ) const visibleStreamActivityKey = getVisibleStreamActivityKey(segments) /** Decided once per parse, not on every idle-timer or text-reveal render. */ + const actionLayout = useAssistantActionLayout() const liveIndicators = useMemo( - () => getMessageLiveIndicators(segments, isStreaming), - [segments, isStreaming] + () => getMessageLiveIndicators(segments, isStreaming, actionLayout), + [segments, isStreaming, actionLayout] ) // Every visible stream update restarts the quiet-period clock. A layout diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index e382f6ae427..661ec14641a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -114,6 +114,7 @@ interface MothershipChatProps { onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void draftScopeKey?: string layout?: 'mothership-view' | 'copilot-view' + reserveResourceHeader?: boolean initialScrollBlocked?: boolean animateInput?: boolean onInputAnimationEnd?: () => void @@ -402,6 +403,7 @@ export function MothershipChat({ onWorkspaceResourceSelect, draftScopeKey, layout = 'mothership-view', + reserveResourceHeader = true, initialScrollBlocked = false, animateInput = false, onInputAnimationEnd, @@ -874,6 +876,17 @@ export function MothershipChat({ }, [lastUserMessageId, lastIndex, isSending, initialScrollBlocked, virtualizer, find.isOpen]) const virtualItems = virtualizer.getVirtualItems() + const receiptQueryOwners = new Map() + const mountedReceiptMessages = new Map() + for (const item of virtualItems) { + const message = messages[item.index] + if (message.streamId) { + receiptQueryOwners.set(message.streamId, message.id) + const ids = mountedReceiptMessages.get(message.streamId) ?? [] + ids.push(message.id) + mountedReceiptMessages.set(message.streamId, ids) + } + } return ( - + {msg.role === 'user' ? ( interactionPairing.hiddenUserByIndex[index] ? null : msg.origin ? ( +export function HomeFallback({ header }: HomeFallbackProps) { + return ( +
+ {header &&
{header}
} +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts index 776c10f7535..834735f1bcb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/chat-url.ts @@ -2,18 +2,18 @@ import { organizationRoutes } from '@/lib/navigation/paths' import { resourceParam } from '@/app/workspace/[workspaceId]/home/search-params' import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' -/** Preserve the selected resource when a new conversation receives its chat URL. */ +/** Preserve the selected resource when a new conversation receives its default chat URL. */ export function chatUrl( owner: string | { organizationId: string }, chatId: string, mode?: ChatRequestMode ): string { + const routes = typeof owner === 'object' ? organizationRoutes(owner.organizationId) : null const current = new URLSearchParams(window.location.search) const carried = new URLSearchParams() const resource = current.get(resourceParam.key) if (resource) carried.set(resourceParam.key, resource) const search = carried.toString() - const routes = typeof owner === 'object' ? organizationRoutes(owner.organizationId) : null const path = routes ? mode === 'assistant' ? routes.searchChat(chatId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts index d5d895f2857..47a3250fac4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts @@ -128,6 +128,7 @@ function toRawPersistedContentBlockBody(block: ContentBlock): PersistedContentBl export function buildAssistantSnapshotMessage(params: { id: string + streamId?: string content: string contentBlocks: ContentBlock[] requestId?: string @@ -138,6 +139,7 @@ export function buildAssistantSnapshotMessage(params: { return normalizeMessage({ id: params.id, + ...(params.streamId ? { streamId: params.streamId } : {}), role: 'assistant', content: params.content, timestamp: new Date().toISOString(), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts index 2cfef4249de..00a7b942797 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts @@ -18,23 +18,8 @@ function chatSessionEvent(chatId: string): PersistedStreamEventEnvelope { } describe('handleSessionEvent', () => { - it.each([ - ['assistant', '/o/organization-1/search/chat/chat-new'], - ['agent', '/o/organization-1/chat/chat-new'], - ['plan', '/o/organization-1/chat/chat-new'], - ] as const)( - 'keeps a newly created %s conversation on its owning surface', - (requestMode, path) => { - window.history.replaceState(null, '', '/o/organization-1/search?view=assistant') - const deps = { ...makeStreamLoopDeps(), organizationId: 'organization-1', requestMode } - handleSessionEvent({ deps } as StreamLoopContext, chatSessionEvent('chat-new') as never) - expect(window.location.pathname).toBe(path) - } - ) - it('adopts a newly assigned chat before chatIdRef is written', () => { const deps = makeStreamLoopDeps() - deps.workflowIdRef.current = 'workflow-1' vi.mocked(deps.adoptResolvedChatId).mockImplementation((chatId) => { expect(deps.chatIdRef.current).toBeUndefined() deps.chatIdRef.current = chatId @@ -42,7 +27,7 @@ describe('handleSessionEvent', () => { handleSessionEvent({ deps } as StreamLoopContext, chatSessionEvent('chat-new') as never) - expect(deps.adoptResolvedChatId).toHaveBeenCalledWith('chat-new') + expect(deps.adoptResolvedChatId).toHaveBeenCalledWith('chat-new', { replaceHomeHistory: true }) expect(deps.chatIdRef.current).toBe('chat-new') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts index 55103f21c02..2dced15c209 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts @@ -1,7 +1,6 @@ import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id' import { MothershipStreamV1SessionKind } from '@/lib/mothership/generated/mothership-stream-v1' import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' -import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' @@ -24,7 +23,7 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent) // This synchronously migrates provisional browser/terminal ownership // before writing chatIdRef. Assigning the ref here first would make the // adoption path believe there was no provisional scope to move. - deps.adoptResolvedChatId(payloadChatId) + deps.adoptResolvedChatId(payloadChatId, { replaceHomeHistory: true }) } else { deps.chatIdRef.current = payloadChatId if (payloadChatId === selected) { @@ -41,6 +40,7 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent) const activeStreamId = deps.streamIdRef.current if (userMsg && activeStreamId) { const assistantMessage = deps.buildAssistantSnapshotMessage({ + streamId: activeStreamId, id: deps.activeTurnRef.current?.assistantMessageId ?? getLiveAssistantMessageId(activeStreamId), @@ -60,17 +60,6 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent) ) } deps.setPendingMessages([]) - if (!deps.workflowIdRef.current) { - window.history.replaceState( - null, - '', - chatUrl( - deps.organizationId ? { organizationId: deps.organizationId } : deps.workspaceId!, - payloadChatId, - deps.requestMode - ) - ) - } } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 691393fddaf..8acb6b2c2ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -115,7 +115,7 @@ export interface StreamLoopDeps { setPendingMessages: Dispatch> setResolvedChatId: Dispatch> /** Adopts a server-created chat before any event handler mutates chatIdRef. */ - adoptResolvedChatId: (chatId: string) => void + adoptResolvedChatId: (chatId: string, options?: { replaceHomeHistory?: boolean }) => void setResources: Dispatch> setActiveResourceId: Dispatch> @@ -158,6 +158,7 @@ export interface StreamLoopDeps { buildAssistantSnapshotMessage: (params: { id: string + streamId?: string content: string contentBlocks: ContentBlock[] requestId?: string @@ -184,7 +185,6 @@ export interface StreamLoopDeps { pendingUserMsgRef: MutableRefObject activeTurnRef: MutableRefObject resourcesRef: MutableRefObject - workflowIdRef: MutableRefObject activeResourceIdRef: MutableRefObject onTitleUpdateRef: MutableRefObject<(() => void) | undefined> onToolResultRef: MutableRefObject< @@ -292,6 +292,7 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext const snapshot: Partial = { content: modelContent, contentBlocks: modelBlocks, + ...(deps.streamIdRef.current ? { streamId: deps.streamIdRef.current } : {}), } if (state.streamRequestId) snapshot.requestId = state.streamRequestId deps.setPendingMessages((prev) => { @@ -312,6 +313,7 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext const assistantMessage = deps.buildAssistantSnapshotMessage({ id: deps.assistantId, + ...(deps.streamIdRef.current ? { streamId: deps.streamIdRef.current } : {}), content: modelContent, contentBlocks: modelBlocks, ...(state.streamRequestId ? { requestId: state.streamRequestId } : {}), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts index 7167f21d267..eab43892e2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts @@ -79,7 +79,6 @@ export function makeStreamLoopDeps(overrides: Partial = {}): Str pendingUserMsgRef: ref(null), activeTurnRef: ref(null), resourcesRef: ref([]), - workflowIdRef: ref(undefined), activeResourceIdRef: ref(null), onTitleUpdateRef: ref<(() => void) | undefined>(undefined), onToolResultRef: ref< diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index d210de4c57e..77a5192e5f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -657,6 +657,8 @@ export function shouldQueueOutgoingMessage( export interface UseChatOptions { /** Intent for new turns; persisted chat intent remains authoritative on reload. */ requestMode?: ChatRequestMode + /** Build the admitted conversation URL from the owning surface's current view state. */ + getChatUrl?: (chatId: string) => string onResourceEvent?: ResourceEventHandler apiPath?: string stopPath?: string @@ -707,6 +709,7 @@ export function getMothershipUseChatOptions( | 'activeResourceState' | 'onRequestStarted' | 'requestMode' + | 'getChatUrl' > = {} ): UseChatOptions { return { @@ -771,6 +774,8 @@ export function useChat( const [activeResourceId, setActiveResourceId] = options?.activeResourceState ?? internalActiveResourceState const onResourceEventRef = useRef(options?.onResourceEvent) + const getChatUrlRef = useRef(options?.getChatUrl) + getChatUrlRef.current = options?.getChatUrl const revealedSimKeysRef = useRef(null) const revealedSimKeys = (revealedSimKeysRef.current ??= new Map()) onResourceEventRef.current = options?.onResourceEvent @@ -1367,11 +1372,12 @@ export function useChat( window.history.replaceState( null, '', - chatUrl( - organizationId ? { organizationId } : workspaceId!, - chatId, - requestModeRef.current - ) + getChatUrlRef.current?.(chatId) ?? + chatUrl( + organizationId ? { organizationId } : workspaceId!, + chatId, + requestModeRef.current + ) ) } if (options?.invalidateList) { @@ -2414,7 +2420,6 @@ export function useChat( pendingUserMsgRef, activeTurnRef, resourcesRef, - workflowIdRef, activeResourceIdRef, onTitleUpdateRef, onToolResultRef, @@ -3489,6 +3494,7 @@ export function useChat( if (assistantId && activeChatId) { const snapshot = buildAssistantSnapshotMessage({ id: assistantId, + ...(streamIdRef.current ? { streamId: streamIdRef.current } : {}), content: streamingContentRef.current, contentBlocks: blocks, ...(streamRequestIdRef.current ? { requestId: streamRequestIdRef.current } : {}), @@ -3690,6 +3696,7 @@ export function useChat( })) const cachedUserMsg: PersistedMessage = { id: userMessageId, + streamId: userMessageId, role: 'user' as const, requestMode: options?.requestMode ?? 'agent', content: message, @@ -3709,6 +3716,7 @@ export function useChat( const optimisticUserMessage: ChatMessage = { id: userMessageId, + streamId: userMessageId, role: 'user', requestMode: options?.requestMode ?? 'agent', content: message, @@ -3718,6 +3726,7 @@ export function useChat( } const optimisticAssistantMessage: ChatMessage = { id: assistantId, + streamId: userMessageId, role: 'assistant', requestMode: options?.requestMode ?? 'agent', content: '', @@ -3731,6 +3740,7 @@ export function useChat( const applyOptimisticSend = () => { const assistantSnapshot = buildAssistantSnapshotMessage({ id: assistantId, + streamId: userMessageId, content: '', contentBlocks: [], }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 1997858ae44..e768743f4f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -194,6 +194,7 @@ export interface ChatMessageContext { export interface ChatMessage { id: string + streamId?: string role: 'user' | 'assistant' requestMode?: 'agent' | 'assistant' | 'plan' content: string diff --git a/apps/sim/components/assistant-actions/action-preview.tsx b/apps/sim/components/assistant-actions/action-preview.tsx new file mode 100644 index 00000000000..73dd143aa89 --- /dev/null +++ b/apps/sim/components/assistant-actions/action-preview.tsx @@ -0,0 +1,114 @@ +import { useId, useRef, useState } from 'react' +import { + Chip, + cn, + overflowFadeSizeClass, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { ChevronDown, ChevronRight } from '@sim/emcn/icons' +import { assistantActionPresentation } from '@/components/assistant-actions/presentation' +import type { AssistantActionsResponse } from '@/lib/api/contracts/assistant-actions' + +type ReviewField = AssistantActionsResponse['actions'][number]['fields'][number] + +interface ActionPreviewProps { + action: Pick + full?: boolean +} + +/** Readable content first; supplied secondary parameters remain inspectable. */ +export function ActionPreview({ action, full = false }: ActionPreviewProps) { + const [expanded, setExpanded] = useState(false) + const detailsId = useId() + const { targets, primary, details } = assistantActionPresentation(action) + return ( +
+ {targets.length > 0 && ( +
+ {targets.map((field) => ( +
+
{field.label}
+
+ {field.value} +
+
+ ))} +
+ )} + {primary.map((field, index) => ( + + ))} + {details.length > 0 && ( + <> + {!full && ( +
+ setExpanded(!expanded)} + > + Details + +
+ )} + {(expanded || full) && ( +
+ {details.map((field) => ( + + ))} +
+ )} + + )} +
+ ) +} + +interface PreviewFieldProps { + field: ReviewField + primary?: boolean + prominent?: boolean +} + +function PreviewField({ field, primary = false, prominent = false }: PreviewFieldProps) { + const scroll = useRef(null) + const edges = useScrollEdges(scroll) + let value = field.value + if (field.format === 'json') { + try { + value = JSON.stringify(JSON.parse(value), null, 2) + } catch {} + } + return ( +
+ {(!primary || field.format === 'json') && ( +
{field.label}
+ )} +
+
+ {value} +
+
+
+ ) +} diff --git a/apps/sim/components/assistant-actions/action-receipt.tsx b/apps/sim/components/assistant-actions/action-receipt.tsx new file mode 100644 index 00000000000..c6b39fadae4 --- /dev/null +++ b/apps/sim/components/assistant-actions/action-receipt.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { Chip, cn, OverflowText } from '@sim/emcn' +import { ChevronDown, ChevronRight, ShieldCheck } from '@sim/emcn/icons' +import { ActionPreview } from '@/components/assistant-actions/action-preview' +import { assistantActionPresentation } from '@/components/assistant-actions/presentation' +import type { AssistantActionReceiptsResponse } from '@/lib/api/contracts/assistant-actions' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' +import { InteractionCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card' +import { BrandIcon } from '@/blocks/brand-icon' + +type ActionReceipt = AssistantActionReceiptsResponse['receipts'][number] + +const STATE_LABELS: Record = { + pending: 'Waiting for review', + approved: 'Approved', + running: 'Running', + succeeded: 'Completed', + cancelled: 'Cancelled', + failed: 'Failed', + uncertain: 'Outcome unknown', +} + +interface AssistantActionReceiptProps { + receipt: ActionReceipt +} + +/** Durable execution state and the exact approved fields, independent of transcript tool status. */ +export function AssistantActionReceipt({ receipt }: AssistantActionReceiptProps) { + const [expanded, setExpanded] = useState(false) + const presentation = assistantActionPresentation(receipt) + const display = receipt.account + ? resolveCredentialDisplay({ ...receipt.account, type: 'managed_oauth' }) + : undefined + return ( +
+ + {display?.icon ? ( + + ) : ( + + )} + {presentation.title} + + } + actions={ + setExpanded(!expanded)} + > + {STATE_LABELS[receipt.state]} + + } + > +
+ {!expanded && presentation.primary[0] && ( + + )} + {receipt.state === 'uncertain' && ( +

Check the app before retrying.

+ )} + {receipt.warning && ( +

+ {receipt.warning} +

+ )} + {expanded && ( + <> +

+ {receipt.account?.displayName ?? 'Account unavailable'} +

+ {receipt.detailsAvailable ? ( + + ) : ( +

+ Details are no longer available for this action. +

+ )} + + )} +
+
+
+ ) +} diff --git a/apps/sim/components/assistant-actions/index.ts b/apps/sim/components/assistant-actions/index.ts index f173ee786cc..3196a4f864e 100644 --- a/apps/sim/components/assistant-actions/index.ts +++ b/apps/sim/components/assistant-actions/index.ts @@ -2,5 +2,6 @@ export { AssistantActionReviewMessage, AssistantActionReviewProvider, AssistantActionReviewStatus, + useAssistantActionLayout, useHasAssistantActionReview, } from './review' diff --git a/apps/sim/components/assistant-actions/presentation.ts b/apps/sim/components/assistant-actions/presentation.ts new file mode 100644 index 00000000000..c023e4e2968 --- /dev/null +++ b/apps/sim/components/assistant-actions/presentation.ts @@ -0,0 +1,45 @@ +import type { AssistantActionsResponse } from '@/lib/api/contracts/assistant-actions' + +type PresentedAction = Pick< + AssistantActionsResponse['actions'][number], + 'toolId' | 'name' | 'fields' +> + +/** Display labels and preview fields only; model-facing tool definitions remain unchanged. */ +const PRESENTATIONS: Record = { + gmail_draft_v2: { title: 'Draft email', primary: ['subject', 'body'] }, + gmail_send_v2: { title: 'Send email', primary: ['subject', 'body'] }, + google_docs_create: { title: 'Create Google Doc', primary: ['title', 'content'] }, + google_docs_write: { title: 'Update Google Doc', primary: ['content'] }, + google_sheets_write_v2: { title: 'Update spreadsheet', primary: ['values'] }, + jira_add_comment: { title: 'Add Jira comment', primary: ['body'] }, + jira_create_sprint: { title: 'Create Jira sprint', primary: ['name', 'goal'] }, + jira_update: { title: 'Update Jira issue', primary: ['summary', 'description'] }, + jira_write: { title: 'Create Jira issue', primary: ['summary', 'description'] }, + microsoft_excel_write_v2: { title: 'Update Excel spreadsheet', primary: ['values', 'formulas'] }, + microsoft_teams_write_channel: { title: 'Send Teams message', primary: ['content'] }, + microsoft_teams_write_chat: { title: 'Send Teams message', primary: ['content'] }, + outlook_draft: { title: 'Draft email', primary: ['subject', 'body'] }, + outlook_send: { title: 'Send email', primary: ['subject', 'body'] }, + salesforce_create_task: { title: 'Create Salesforce task', primary: ['subject', 'description'] }, + salesforce_update_task: { title: 'Update Salesforce task', primary: ['subject', 'description'] }, + slack_message: { title: 'Send Slack message', primary: ['blocks', 'text'] }, +} + +export function assistantActionPresentation(action: PresentedAction) { + const presentation = PRESENTATIONS[action.toolId] + const fields = action.fields.filter((field) => field.value !== '') + const targets = fields.filter((field) => field.target) + const primary = presentation + ? presentation.primary.flatMap((name) => + fields.filter((field) => field.name === name && !field.target) + ) + : fields.filter((field) => !field.target).slice(0, 2) + const primaryNames = new Set(primary.map((field) => field.name)) + return { + title: presentation?.title ?? action.name, + targets, + primary, + details: fields.filter((field) => !field.target && !primaryNames.has(field.name)), + } +} diff --git a/apps/sim/components/assistant-actions/review.tsx b/apps/sim/components/assistant-actions/review.tsx index e91372b3a7a..f6f5c1df4c7 100644 --- a/apps/sim/components/assistant-actions/review.tsx +++ b/apps/sim/components/assistant-actions/review.tsx @@ -13,8 +13,14 @@ import { Chip, ChipInput, ChipTextarea, cn } from '@sim/emcn' import { ShieldCheck } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' +import { ActionPreview } from '@/components/assistant-actions/action-preview' +import { AssistantActionReceipt } from '@/components/assistant-actions/action-receipt' +import { assistantActionPresentation } from '@/components/assistant-actions/presentation' import { ActivityStatus } from '@/components/ui/activity-status' -import type { AssistantActionsResponse } from '@/lib/api/contracts/assistant-actions' +import type { + AssistantActionReceiptsResponse, + AssistantActionsResponse, +} from '@/lib/api/contracts/assistant-actions' import { ASSISTANT_REVIEW_FIELD_MAX_CHARS } from '@/lib/assistant-actions/limits' import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id' @@ -22,12 +28,17 @@ import { InteractionCard } from '@/app/workspace/[workspaceId]/home/components/m import { type ChatMessage, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { BrandIcon } from '@/blocks/brand-icon' import { + useAssistantActionReceiptBatches, useAssistantActionReview, + useAssistantActionTurnReceipts, useDecideAssistantAction, + useRefreshAssistantActionReceipts, } from '@/hooks/queries/assistant-actions' import { getToolMetadata } from '@/tools/metadata' type ReviewAction = AssistantActionsResponse['actions'][number] +type Receipt = AssistantActionReceiptsResponse['receipts'][number] +type ReceiptBatchQuery = ReturnType[number]['query'] type ReviewEdits = Record }> type ReviewErrors = Record @@ -37,11 +48,24 @@ interface ReviewDrafts { errors: ReviewErrors } +interface ReviewTurn { + messageId: string + representedIds: Set + hasApprovalRows: boolean + writeIds: Set +} + interface ReviewContextValue { + organizationId: string + chatId: string + active: boolean + streamId: string | null + messageStreams: ReadonlyMap + messageReceiptIds: ReadonlyMap> + turns: ReadonlyMap query: ReturnType actions: ReviewAction[] byId: ReadonlyMap - assistantMessageId: string | null recoveryMessageId: string | null recoveryActions: ReviewAction[] hasApprovalRows: boolean @@ -58,6 +82,11 @@ interface ReviewContextValue { interface ReviewMessageContextValue { messageId: string + streamId?: string + receipts: ReadonlyMap + receiptQuery: ReturnType + receiptQueries: ReadonlyMap + layout: { standaloneToolIds: ReadonlySet; reviewedToolIds: ReadonlySet } submit: (actions: ReviewAction[], decision: 'allow' | 'skip', source: HTMLButtonElement) => void } @@ -65,6 +94,15 @@ const reviewContext = createContext(null) const reviewMessageContext = createContext(null) const pendingReviewContext = createContext(false) const EMPTY_ACTIONS: ReviewAction[] = [] +const EMPTY_ACTION_LAYOUT = { + standaloneToolIds: new Set(), + reviewedToolIds: new Set(), +} + +/** Keeps bound actions visible without changing ordinary tool activity or workspace chat. */ +export function useAssistantActionLayout() { + return useContext(reviewMessageContext)?.layout ?? EMPTY_ACTION_LAYOUT +} /** Lets the current turn yield its activity footer to pending review controls. */ export function useHasAssistantActionReview() { @@ -91,6 +129,7 @@ export function AssistantActionReviewProvider({ const inFlight = useRef(null) const query = useAssistantActionReview(organizationId, chatId ?? '', active, enabled) const mutation = useDecideAssistantAction(organizationId, chatId ?? '') + const refreshReceipts = useRefreshAssistantActionReceipts(organizationId, chatId ?? '') const [submitting, setSubmitting] = useState(false) const [drafts, setDrafts] = useState({ runId: null, edits: {}, errors: {} }) const actions = query.data?.actions ?? EMPTY_ACTIONS @@ -100,43 +139,64 @@ export function AssistantActionReviewProvider({ () => new Map(actions.map((action) => [action.toolCallId, action])), [actions] ) - const { assistantMessageId, recoveryMessageId, recoveryActions, hasApprovalRows } = - useMemo(() => { - if (!streamId) { - const hasApprovalRows = messages.some((message) => - message.contentBlocks?.some( - ({ toolCall }) => - toolCall?.status === ToolCallStatus.awaiting_approval && - getToolMetadata(toolCall.name)?.assistant + const { messageStreams, messageReceiptIds, turns } = useMemo(() => { + const messageReceiptIds = new Map>() + const messageStreams = new Map() + const turns = new Map() + for (const message of messages) { + messageReceiptIds.set( + message.id, + new Set( + message.contentBlocks?.flatMap(({ toolCall }) => + toolCall && + getToolMetadata(toolCall.name)?.assistant && + (getToolMetadata(toolCall.name)?.assistant?.effect === 'write' || + toolCall.status === ToolCallStatus.awaiting_approval) + ? [toolCall.id] + : [] ) ) - return { - assistantMessageId: null, - recoveryMessageId: hasApprovalRows ? null : (messages.at(-1)?.id ?? null), - recoveryActions: EMPTY_ACTIONS, - hasApprovalRows, - } + ) + const messageStreamId = + message.streamId ?? + (streamId && (message.id === streamId || message.id === getLiveAssistantMessageId(streamId)) + ? streamId + : undefined) + if (!messageStreamId) continue + messageStreams.set(message.id, messageStreamId) + const turn = turns.get(messageStreamId) ?? { + messageId: message.id, + representedIds: new Set(), + hasApprovalRows: false, + writeIds: new Set(), } - const messageId = getLiveAssistantMessageId(streamId) - const assistant = messages.find((message) => message.id === messageId) - const representedIds = new Set( - assistant?.contentBlocks?.flatMap(({ toolCall }) => + turn.messageId = message.id + for (const { toolCall } of message.contentBlocks ?? []) { + if (!toolCall || !getToolMetadata(toolCall.name)?.assistant) continue + turn.representedIds.add(toolCall.id) + if (getToolMetadata(toolCall.name)?.assistant?.effect === 'write') + turn.writeIds.add(toolCall.id) + if (toolCall.status === ToolCallStatus.awaiting_approval) turn.hasApprovalRows = true + } + turns.set(messageStreamId, turn) + } + return { messageStreams, messageReceiptIds, turns } + }, [messages, streamId]) + const currentTurn = streamId ? turns.get(streamId) : undefined + const hasApprovalRows = + currentTurn?.hasApprovalRows ?? + messages.some((message) => + message.contentBlocks?.some( + ({ toolCall }) => toolCall?.status === ToolCallStatus.awaiting_approval && getToolMetadata(toolCall.name)?.assistant - ? [toolCall.id] - : [] - ) ) - return { - assistantMessageId: messageId, - recoveryMessageId: assistant - ? messageId - : (messages.find((message) => message.role === 'user' && message.id === streamId)?.id ?? - null), - recoveryActions: actions.filter((action) => !representedIds.has(action.toolCallId)), - hasApprovalRows: representedIds.size > 0, - } - }, [actions, messages, streamId]) + ) + const recoveryMessageId = + currentTurn?.messageId ?? (hasApprovalRows ? null : (messages.at(-1)?.id ?? null)) + const recoveryActions = actions.filter( + (action) => !currentTurn?.representedIds.has(action.toolCallId) + ) useEffect(() => () => inFlight.current?.abort(), [runId]) @@ -224,6 +284,7 @@ export function AssistantActionReviewProvider({ } } } finally { + await refreshReceipts() if (inFlight.current === controller) { inFlight.current = null setSubmitting(false) @@ -239,7 +300,13 @@ export function AssistantActionReviewProvider({ query, actions, byId, - assistantMessageId, + organizationId, + chatId: chatId ?? '', + active, + streamId, + messageStreams, + messageReceiptIds, + turns, recoveryMessageId, recoveryActions, hasApprovalRows, @@ -259,17 +326,104 @@ export function AssistantActionReviewProvider({ interface AssistantActionReviewMessageProps { messageId: string + isReceiptQueryOwner: boolean + mountedReceiptTurnCount: number + mountedMessageIds: readonly string[] children: ReactNode } export function AssistantActionReviewMessage({ messageId, + isReceiptQueryOwner, + mountedReceiptTurnCount, + mountedMessageIds, children, }: AssistantActionReviewMessageProps) { const turn = useRef(null) const focusOrigin = useRef(null) const review = useContext(reviewContext) const submitting = review?.submitting ?? false + const streamId = review?.messageStreams.get(messageId) + const receiptQuery = useAssistantActionTurnReceipts({ + organizationId: review?.organizationId ?? '', + chatId: review?.chatId ?? '', + streamId: streamId ?? '', + active: Boolean(review?.active && review.streamId === streamId), + enabled: isReceiptQueryOwner, + mountedTurnCount: mountedReceiptTurnCount, + }) + const pagedReceipts = new Map( + receiptQuery.data?.pages + .flatMap((page) => page.receipts) + .map((receipt) => [receipt.toolCallId, receipt]) + ) + const receiptTurn = streamId ? review?.turns.get(streamId) : undefined + const candidates = new Set([ + ...mountedMessageIds.flatMap((id) => [...(review?.messageReceiptIds.get(id) ?? [])]), + ...(streamId && streamId === review?.streamId ? review.byId.keys() : []), + ]) + const receiptBatches = useAssistantActionReceiptBatches({ + organizationId: review?.organizationId ?? '', + chatId: review?.chatId ?? '', + streamId: streamId ?? messageId, + candidateIds: candidates, + coveredIds: new Set(pagedReceipts.keys()), + enabled: isReceiptQueryOwner && (!streamId || receiptQuery.isSuccess || receiptQuery.isError), + pageCount: receiptQuery.data?.pages.length ?? 1, + mountedTurnCount: mountedReceiptTurnCount, + }) + const receiptQueries = new Map( + receiptBatches.flatMap(({ ids, query }) => ids.map((id) => [id, query] as const)) + ) + const receipts = new Map([ + ...receiptBatches + .flatMap(({ query }) => query.data?.receipts ?? []) + .map((receipt) => [receipt.toolCallId, receipt] as const), + ...pagedReceipts, + ]) + const layout = { + standaloneToolIds: new Set([ + ...(receiptTurn?.writeIds ?? []), + ...receipts.keys(), + ...(streamId && streamId === review?.streamId ? review.byId.keys() : []), + ]), + reviewedToolIds: new Set( + [...receipts.values()] + .filter((receipt) => receipt.state !== 'pending') + .map((receipt) => receipt.toolCallId) + ), + } + const ownsReceipts = receiptTurn?.messageId === messageId + const missingRecovery = ownsReceipts + ? receiptBatches.filter(({ ids }) => + ids.some( + (id) => !receiptTurn.representedIds.has(id) && !review?.byId.has(id) && !receipts.has(id) + ) + ) + : [] + const recoveryReceipts = ownsReceipts + ? [...receipts.values()].filter( + (receipt) => + !receiptTurn.representedIds.has(receipt.toolCallId) && + !review?.byId.has(receipt.toolCallId) + ) + : [] + const ownsReview = review?.recoveryMessageId === messageId + const recovery = new Map< + string, + { kind: 'review'; value: ReviewAction } | { kind: 'receipt'; value: Receipt } + >() + for (const receipt of recoveryReceipts) + recovery.set(receipt.toolCallId, { kind: 'receipt', value: receipt }) + if (ownsReview) { + for (const action of review.recoveryActions) + recovery.set(action.toolCallId, { kind: 'review', value: action }) + } + const recoveryItems = [...recovery.values()].sort( + (left, right) => + (left.value.sequence ?? Number.MAX_SAFE_INTEGER) - + (right.value.sequence ?? Number.MAX_SAFE_INTEGER) + ) useEffect(() => { const origin = focusOrigin.current @@ -305,11 +459,15 @@ export function AssistantActionReviewMessage({ focusOrigin.current = document.activeElement === source ? source : null }) } - const ownsReview = review.recoveryMessageId === messageId return ( - + 0 || review.submitting)} + value={ + (ownsReview && (review.actions.length > 0 || review.submitting)) || + (ownsReceipts && [...receipts.values()].some((receipt) => receipt.unsettled)) + } >
- +
)} - {ownsReview && review.recoveryActions.length > 0 && ( -
- {review.recoveryActions.map((action) => ( - + {recoveryItems.length > 0 && ( +
+ {recoveryItems.map((item) => ( +
+ {item.kind === 'review' ? ( + + ) : ( + + )} +
))}
)} + {missingRecovery.length > 0 && ( +
+ query.isLoading) + ? 'Loading action history' + : 'Action details unavailable' + } + isActive={missingRecovery.some(({ query }) => query.isFetching)} + /> + {missingRecovery.some(({ query }) => query.isError) && ( + { + for (const { query } of missingRecovery) void query.refetch() + }} + > + Retry + + )} +
+ )} + {ownsReceipts && receiptQuery.isError && ( +
+ + void receiptQuery.refetch()}> + Retry + +
+ )} + {ownsReceipts && receiptQuery.hasNextPage && ( +
+ void receiptQuery.fetchNextPage()} + > + More action history + +
+ )} {ownsReview && }
@@ -349,39 +552,55 @@ interface AssistantActionReviewStatusProps { toolCallId: string title: string fallback: ReactNode + awaitingApproval?: boolean } export function AssistantActionReviewStatus({ toolCallId, title, fallback, + awaitingApproval = true, }: AssistantActionReviewStatusProps) { const review = useContext(reviewContext) const message = useContext(reviewMessageContext) + const receipt = message?.receipts.get(toolCallId) if (!review) return fallback - if (review.query.isPending) return - if (message?.messageId === review.assistantMessageId) { + if (message?.streamId === review.streamId) { const action = review.byId.get(toolCallId) if (action) return - if (!review.query.isError && review.query.data?.nextCursor != null) - return } + if (receipt) return + const batch = message?.receiptQueries.get(toolCallId) + if (!awaitingApproval && !batch) return fallback + if (review.query.isPending || message?.receiptQuery.isLoading || batch?.isPending) + return + if ( + message?.streamId === review.streamId && + !review.query.isError && + review.query.data?.nextCursor != null + ) + return if (review.query.isError) - return + return + if (batch?.isError) + return + if (message?.receiptQuery.isError && !batch?.isSuccess) + return + if (!awaitingApproval) return fallback return } interface ReviewAvailabilityProps { label: string - review?: ReviewContextValue + query?: { isFetching: boolean; refetch: () => Promise } } -function ReviewAvailability({ label, review }: ReviewAvailabilityProps) { +function ReviewAvailability({ label, query }: ReviewAvailabilityProps) { return (
} /> - {review && ( - void review.query.refetch()}> + {query && ( + void query.refetch()}> Retry )} @@ -395,6 +614,8 @@ interface ActionReviewProps { } function ActionReview({ action, review }: ActionReviewProps) { + const [editing, setEditing] = useState(false) + const { title } = assistantActionPresentation(action) const message = useContext(reviewMessageContext) const draft = review.drafts.runId === action.runId ? review.drafts.edits[action.toolCallId] : undefined @@ -410,44 +631,63 @@ function ActionReview({ action, review }: ActionReviewProps) { ? resolveCredentialDisplay({ ...action.account, type: 'managed_oauth' }) : undefined return ( -
+
+ {display?.icon ? ( ) : ( )} - {action.name} + {title} } + actions={ + action.fields.some((field) => field.editable) ? ( + setEditing(!editing)}> + {editing ? 'Done' : 'Edit'} + + ) : undefined + } > -
+

{action.account?.displayName ?? 'Account unavailable'}

-
- {action.fields - .filter((field) => field.value || field.editable) - .map((field) => ( - review.edit(action, field.name, value)} - /> - ))} -
+ {editing ? ( +
+ {action.fields + .filter((field) => field.value || field.editable) + .map((field) => ( + review.edit(action, field.name, value)} + /> + ))} +
+ ) : ( + ({ + ...field, + value: edits[field.name] ?? field.value, + })), + }} + /> + )} {!action.account?.active && (

This account is no longer available.

)} {review.query.isError && ( - + )} {error?.proposalVersion === action.proposalVersion && (

diff --git a/apps/sim/components/secrets/organization-secrets-editor.tsx b/apps/sim/components/secrets/organization-secrets-editor.tsx index 60ace35afef..e9f9bccd214 100644 --- a/apps/sim/components/secrets/organization-secrets-editor.tsx +++ b/apps/sim/components/secrets/organization-secrets-editor.tsx @@ -18,9 +18,10 @@ import { interface OrganizationSecretsEditorProps { mode: SecretSourceMode + backHref?: string } -export function OrganizationSecretsEditor({ mode }: OrganizationSecretsEditorProps) { +export function OrganizationSecretsEditor({ mode, backHref }: OrganizationSecretsEditorProps) { const { organization, viewer } = useOrganizationContext() const router = useRouter() const secrets = useOrganizationSecrets(organization.id, mode, mode === 'member' || viewer.isAdmin) @@ -29,7 +30,7 @@ export function OrganizationSecretsEditor({ mode }: OrganizationSecretsEditorPro const back = mode === 'organization' ? { text: 'Sources', href: routes.settingsSection('integrations') } - : { text: 'Integrations', href: routes.integrations } + : { text: 'Integrations', href: backHref ?? routes.integrations } if (!secrets.data || secrets.error) return ( Loading secrets…} > - + diff --git a/apps/sim/hooks/queries/assistant-actions.ts b/apps/sim/hooks/queries/assistant-actions.ts index 72054f3accb..4819379f4b9 100644 --- a/apps/sim/hooks/queries/assistant-actions.ts +++ b/apps/sim/hooks/queries/assistant-actions.ts @@ -1,26 +1,47 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' +import { compareStrings } from '@sim/utils/string' +import { + type InfiniteData, + type QueryClient, + useInfiniteQuery, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type AssistantActionDecisionBody, + type AssistantActionReceiptsResponse, decideAssistantActionContract, + listAssistantActionReceiptsContract, listAssistantActionsContract, readAssistantToolPermissionsContract, type UpdateAssistantToolPermissionsBody, updateAssistantToolPermissionsContract, } from '@/lib/api/contracts/assistant-actions' -const assistantActionKeys = { +export const assistantActionKeys = { all: ['assistant-actions'] as const, permissions: () => [...assistantActionKeys.all, 'permissions'] as const, permission: (organizationId: string, credentialId: string) => [...assistantActionKeys.permissions(), organizationId, credentialId] as const, review: (organizationId: string, chatId: string) => [...assistantActionKeys.all, 'review', organizationId, chatId] as const, + receipts: (organizationId: string, chatId: string) => + [...assistantActionKeys.all, 'receipts', organizationId, chatId] as const, + turnReceipts: (organizationId: string, chatId: string, streamId: string) => + [...assistantActionKeys.receipts(organizationId, chatId), 'turn', streamId] as const, + receiptBatches: (organizationId: string, chatId: string, streamId: string) => + [...assistantActionKeys.receipts(organizationId, chatId), 'batches', streamId] as const, + receiptBatch: (organizationId: string, chatId: string, streamId: string, toolCallIds: string[]) => + [...assistantActionKeys.receiptBatches(organizationId, chatId, streamId), toolCallIds] as const, } const ASSISTANT_PERMISSIONS_STALE_TIME = 10_000 const ASSISTANT_REVIEW_STALE_TIME = 3_000 const ASSISTANT_REVIEW_POLL_INTERVAL = 3_000 +const RECEIPT_BATCH_CONCURRENCY = 2 export function useAssistantToolPermissions(organizationId: string, credentialId: string) { return useQuery({ @@ -70,10 +91,170 @@ export function useDecideAssistantAction(organizationId: string, chatId: string) signal, }), onSettled: () => - client.invalidateQueries({ queryKey: assistantActionKeys.review(organizationId, chatId) }), + Promise.all([ + client.invalidateQueries({ queryKey: assistantActionKeys.review(organizationId, chatId) }), + client.invalidateQueries({ + queryKey: assistantActionKeys.receipts(organizationId, chatId), + refetchType: 'none', + }), + ]), + }) +} + +interface ReceiptTurnOptions { + organizationId: string + chatId: string + streamId: string + enabled: boolean + mountedTurnCount: number +} + +interface ReceiptBatchOptions extends ReceiptTurnOptions { + candidateIds: Iterable + coveredIds: ReadonlySet + pageCount: number +} + +/** Pages only mounted turns; an unresolved execution keeps polling after chat streaming stops. */ +export function useAssistantActionTurnReceipts({ + organizationId, + chatId, + streamId, + active, + enabled, + mountedTurnCount, +}: ReceiptTurnOptions & { active: boolean }) { + const client = useQueryClient() + return useInfiniteQuery({ + queryKey: assistantActionKeys.turnReceipts(organizationId, chatId, streamId), + queryFn: ({ signal, pageParam }) => + requestJson(listAssistantActionReceiptsContract, { + params: { id: organizationId, chatId }, + query: { streamId, cursor: pageParam }, + signal, + }), + initialPageParam: undefined as number | undefined, + getNextPageParam: (page) => page.nextCursor ?? undefined, + enabled: enabled && Boolean(organizationId && chatId && streamId), + staleTime: ASSISTANT_REVIEW_STALE_TIME, + refetchInterval: (query) => + enabled && + (active || + query.state.data?.pages.some((page) => page.receipts.some((receipt) => receipt.unsettled))) + ? receiptPollInterval( + client, + organizationId, + chatId, + streamId, + query.state.data?.pages.length ?? 1, + mountedTurnCount + ) + : false, }) } +/** Immutable batch keys retain recovery-only receipts after a decision removes the pending action. */ +export function useAssistantActionReceiptBatches({ + organizationId, + chatId, + streamId, + candidateIds, + coveredIds, + enabled, + pageCount, + mountedTurnCount, +}: ReceiptBatchOptions) { + const client = useQueryClient() + const existing = client.getQueriesData({ + queryKey: assistantActionKeys.receiptBatches(organizationId, chatId, streamId), + }) + const batches = existing.flatMap(([key]) => { + const ids = key.at(-1) + return Array.isArray(ids) && ids.every((id): id is string => typeof id === 'string') + ? [ids] + : [] + }) + const known = new Set([...coveredIds, ...batches.flat()]) + const missing = [...new Set(candidateIds)].filter((id) => !known.has(id)).sort(compareStrings) + for (let index = 0; index < missing.length; index += 32) + batches.push(missing.slice(index, index + 32)) + const interval = receiptPollInterval( + client, + organizationId, + chatId, + streamId, + pageCount, + mountedTurnCount + ) + let initialRequests = 0 + const queries = useQueries({ + queries: batches.map((toolCallIds) => { + const key = assistantActionKeys.receiptBatch(organizationId, chatId, streamId, toolCallIds) + const state = client.getQueryState(key) + const started = state?.status === 'success' || state?.status === 'error' + const uncovered = toolCallIds.some((id) => !coveredIds.has(id)) + const eligible = started || (uncovered && initialRequests++ < RECEIPT_BATCH_CONCURRENCY) + return { + queryKey: key, + queryFn: ({ signal }: { signal: AbortSignal }) => + requestJson(listAssistantActionReceiptsContract, { + params: { id: organizationId, chatId }, + query: { toolCallIds }, + signal, + }), + enabled: enabled && eligible && Boolean(organizationId && chatId && streamId) && uncovered, + staleTime: ASSISTANT_REVIEW_STALE_TIME, + refetchInterval: + enabled && + uncovered && + existing + .find(([key]) => key.at(-1) === toolCallIds)?.[1] + ?.receipts.some((receipt) => receipt.unsettled && !coveredIds.has(receipt.toolCallId)) + ? interval + : false, + } + }), + }) + return batches.map((ids, index) => ({ ids, query: queries[index] })) +} + +/** A review batch refreshes receipts once, after all of its decisions settle. */ +export function useRefreshAssistantActionReceipts(organizationId: string, chatId: string) { + const client = useQueryClient() + return useCallback( + () => + client.invalidateQueries({ queryKey: assistantActionKeys.receipts(organizationId, chatId) }), + [client, organizationId, chatId] + ) +} + +function receiptPollInterval( + client: QueryClient, + organizationId: string, + chatId: string, + streamId: string, + pageCount: number, + mountedTurnCount: number +) { + const pages = client.getQueryData>( + assistantActionKeys.turnReceipts(organizationId, chatId, streamId) + ) + const covered = new Set( + pages?.pages.flatMap((page) => page.receipts.map((receipt) => receipt.toolCallId)) + ) + const batches = client + .getQueriesData({ + queryKey: assistantActionKeys.receiptBatches(organizationId, chatId, streamId), + }) + .filter(([, data]) => + data?.receipts.some((receipt) => receipt.unsettled && !covered.has(receipt.toolCallId)) + ).length + return Math.max( + ASSISTANT_REVIEW_POLL_INTERVAL, + (pageCount + batches) * Math.max(1, mountedTurnCount) * 1_000 + ) +} + export function useUpdateAssistantToolPermissions(organizationId: string, credentialId: string) { const client = useQueryClient() return useMutation({ diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 6fe908733ed..54765a07de1 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -45,6 +45,7 @@ import { import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup' import { isDesktopApp } from '@/lib/desktop' import { connectDesktopSource } from '@/lib/desktop/source-connect' +import { assistantActionKeys } from '@/hooks/queries/assistant-actions' import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' @@ -249,6 +250,7 @@ export function useUpdateOrganizationAccounts() { async function refreshAccounts(client: ReturnType) { await Promise.all([ client.invalidateQueries({ queryKey: organizationAccountsKeys.all }), + client.invalidateQueries({ queryKey: assistantActionKeys.permissions() }), client.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), client.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), invalidateSelectorQueries(client), diff --git a/apps/sim/lib/api/contracts/assistant-actions.ts b/apps/sim/lib/api/contracts/assistant-actions.ts index 112a7518a75..5c0e6306494 100644 --- a/apps/sim/lib/api/contracts/assistant-actions.ts +++ b/apps/sim/lib/api/contracts/assistant-actions.ts @@ -96,6 +96,7 @@ export const listAssistantActionsContract = defineRouteContract({ .array( z.object({ toolCallId: z.string().min(1).max(256), + sequence: z.number().int().nonnegative().nullish(), runId: z.uuid(), toolId: toolIdSchema, name: z.string().max(512), @@ -157,3 +158,59 @@ export const decideAssistantActionContract = defineRouteContract({ export type AssistantActionsResponse = z.output export type AssistantActionDecisionBody = z.input + +export const listAssistantActionReceiptsContract = defineRouteContract({ + method: 'GET', + path: `${reviewPath}/receipts`, + params: reviewParamsSchema, + query: z.union([ + z + .object({ + streamId: z.string().min(1).max(256), + cursor: z.coerce.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + }) + .strict(), + z + .object({ + toolCallIds: z + .union([toolIdSchema, z.array(toolIdSchema).min(1).max(32)]) + .transform((ids) => [...new Set(typeof ids === 'string' ? [ids] : ids)]), + }) + .strict(), + ]), + response: { + mode: 'json', + schema: z.object({ + receipts: z + .array( + listAssistantActionsContract.response.schema.shape.actions.element + .omit({ proposalVersion: true }) + .extend({ + sequence: z.number().int().nonnegative().nullable(), + streamId: z.string().min(1).max(256), + state: z.enum([ + 'pending', + 'approved', + 'running', + 'succeeded', + 'cancelled', + 'failed', + 'uncertain', + ]), + unsettled: z.boolean(), + decision: z.enum(['allow', 'skip', 'allow_chat', 'always_allow']).nullable(), + decidedAt: z.iso.datetime().nullable(), + completedAt: z.iso.datetime().nullable(), + detailsAvailable: z.boolean(), + warning: z.string().max(2048).nullable(), + }) + ) + .max(32), + nextCursor: z.number().int().nonnegative().nullable(), + }), + }, +}) + +export type AssistantActionReceiptsResponse = z.output< + typeof listAssistantActionReceiptsContract.response.schema +> diff --git a/apps/sim/lib/assistant-actions/application/operations.ts b/apps/sim/lib/assistant-actions/application/operations.ts index 3e86b5ee1d3..2a7d37d16be 100644 --- a/apps/sim/lib/assistant-actions/application/operations.ts +++ b/apps/sim/lib/assistant-actions/application/operations.ts @@ -1,6 +1,13 @@ import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' export const assistantActionOperations = { + // permission-group-exempt: reads the owner's existing action history without granting tool access. + receipts: defineOrganizationOperation({ + id: 'assistant_actions.receipts', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session'], + }), list: defineOrganizationOperation({ id: 'assistant_actions.list', minimumRole: 'member', diff --git a/apps/sim/lib/assistant-actions/application/receipts.ts b/apps/sim/lib/assistant-actions/application/receipts.ts new file mode 100644 index 00000000000..8f7bd7dca40 --- /dev/null +++ b/apps/sim/lib/assistant-actions/application/receipts.ts @@ -0,0 +1,179 @@ +import { db } from '@sim/db' +import { copilotAsyncToolCalls, copilotChats, copilotRuns } from '@sim/db/schema' +import { truncateAtCodePoint } from '@sim/utils/string' +import { and, asc, eq, gt, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' +import type { AssistantActionReceiptsResponse } from '@/lib/api/contracts/assistant-actions' +import { assistantActionOperations } from '@/lib/assistant-actions/application/operations' +import { assistantReviewFields } from '@/lib/assistant-actions/proposal' +import { loadAssistantAccount } from '@/lib/assistant-actions/tool-permissions' +import { + defineAuthorizedOrganizationUseCase, + type OrganizationUseCaseContext, +} from '@/lib/core/application/authorized-organization-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TERMINAL_RUN_STATUSES } from '@/lib/mothership/async-runs/repository' +import { + requireOwnedChatReadable, + resolveOwnedChatContext, +} from '@/lib/mothership/chat/application/context' +import { toolStartWindowClosed } from '@/lib/mothership/request/tools/deadline' +import { getToolMetadata } from '@/tools/metadata' + +type ReceiptInput = { organizationId: string; chatId: string } & ( + | { streamId: string; cursor?: number } + | { toolCallIds: string[] } +) +type ReceiptState = AssistantActionReceiptsResponse['receipts'][number]['state'] + +const receiptColumns = { + toolCallId: copilotAsyncToolCalls.toolCallId, + runId: copilotAsyncToolCalls.runId, + streamId: copilotRuns.streamId, + currentStreamId: copilotChats.conversationId, + runStatus: copilotRuns.status, + closedAt: copilotRuns.toolAdmissionClosedAt, + executableName: copilotAsyncToolCalls.executableName, + toolName: copilotAsyncToolCalls.toolName, + args: sql`coalesce(${copilotAsyncToolCalls.approvedArgs}, ${copilotAsyncToolCalls.args})`, + binding: copilotAsyncToolCalls.assistantAction, + status: copilotAsyncToolCalls.status, + decision: copilotAsyncToolCalls.permissionDecision, + requestedAt: copilotAsyncToolCalls.permissionRequestedAt, + decidedAt: copilotAsyncToolCalls.permissionDecidedAt, + completedAt: copilotAsyncToolCalls.completedAt, + persistSeq: copilotAsyncToolCalls.persistSeq, + startedAt: copilotAsyncToolCalls.executionStartedAt, + settledAt: copilotAsyncToolCalls.executionSettledAt, + revokedAt: copilotAsyncToolCalls.executionRevokedAt, + leaseExpiresAt: copilotAsyncToolCalls.executionLeaseExpiresAt, + deadlineAt: copilotAsyncToolCalls.executionDeadlineAt, + outcomeUnknown: sql`coalesce(${copilotAsyncToolCalls.result}->'outcomeUnknown' = 'true'::jsonb, false) or coalesce(${copilotAsyncToolCalls.result}#>'{output,outcomeUnknown}' = 'true'::jsonb, false)`, + warning: sql< + string | null + >`case when ${copilotAsyncToolCalls.executableName} = 'jira_write' then left(${copilotAsyncToolCalls.result}#>>'{output,warning}', 2048) else null end`, +} + +/** History remains readable after a turn closes; it never grants authority to execute it. */ +export const listOrganizationAssistantActionReceipts = defineAuthorizedOrganizationUseCase({ + operation: assistantActionOperations.receipts, + async authorizeResource({ principal, input, context }: OrganizationUseCaseContext) { + const chat = await resolveOwnedChatContext(principal, input.chatId) + if (chat.organizationId !== context.organizationId || chat.mode !== 'assistant') + throw new OrchestrationError('not_found', 'Chat not found') + await requireOwnedChatReadable(chat) + }, + async execute({ + input, + context, + }: OrganizationUseCaseContext): Promise { + const records = await db + .select(receiptColumns) + .from(copilotAsyncToolCalls) + .innerJoin(copilotRuns, eq(copilotRuns.id, copilotAsyncToolCalls.runId)) + .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) + .where( + and( + eq(copilotRuns.chatId, input.chatId), + eq(copilotRuns.organizationId, context.organizationId), + eq(copilotRuns.userId, context.userId), + eq(copilotChats.organizationId, context.organizationId), + eq(copilotChats.userId, context.userId), + isNull(copilotChats.deletedAt), + isNotNull(copilotAsyncToolCalls.assistantAction), + or( + isNotNull(copilotAsyncToolCalls.permissionRequestedAt), + sql`${copilotAsyncToolCalls.assistantAction}->>'effect' = 'write'` + ), + 'streamId' in input + ? eq(copilotRuns.streamId, input.streamId) + : inArray(copilotAsyncToolCalls.toolCallId, input.toolCallIds), + 'cursor' in input && input.cursor !== undefined + ? gt(copilotAsyncToolCalls.persistSeq, input.cursor) + : undefined + ) + ) + .orderBy(asc(copilotAsyncToolCalls.persistSeq)) + .limit(33) + const page = records.slice(0, 32) + const credentialIds = [ + ...new Set(page.flatMap((record) => (record.binding ? [record.binding.credentialId] : []))), + ] + const accounts = new Map( + await Promise.all( + credentialIds.map( + async (credentialId) => + [credentialId, await loadAssistantAccount({ ...context, credentialId })] as const + ) + ) + ) + const now = Date.now() + const receipts = page.flatMap((record) => { + if (!record.binding) return [] + const account = accounts.get(record.binding.credentialId) + const toolId = record.executableName ?? record.toolName + const tool = getToolMetadata(toolId) + const detailsAvailable = Boolean( + tool?.assistant && tool.assistant.version === record.binding.operationVersion + ) + const running = + !record.settledAt && + !record.revokedAt && + record.leaseExpiresAt !== null && + record.leaseExpiresAt.getTime() > now + const closed = + record.closedAt !== null || + TERMINAL_RUN_STATUSES.includes(record.runStatus) || + record.currentStreamId !== record.streamId || + toolStartWindowClosed(record.deadlineAt?.getTime(), now) + let state: ReceiptState + if (record.outcomeUnknown) state = 'uncertain' + else if (record.status === 'completed') state = 'succeeded' + else if ( + (record.startedAt && record.revokedAt) || + (record.startedAt && record.status === 'cancelled') + ) + state = 'uncertain' + else if (record.decision === 'skip' || record.status === 'cancelled') state = 'cancelled' + else if (record.status === 'failed') state = 'failed' + else if (running) state = 'running' + else if (record.startedAt || record.status === 'delivered') state = 'uncertain' + else if (closed) state = 'cancelled' + else state = record.decision || record.requestedAt === null ? 'approved' : 'pending' + return [ + { + toolCallId: record.toolCallId, + sequence: record.persistSeq, + runId: record.runId, + streamId: record.streamId, + toolId, + name: tool?.name ?? record.toolName, + effect: record.binding.effect, + state, + unsettled: + running || + state === 'approved' || + state === 'pending' || + (!closed && + !record.settledAt && + !record.revokedAt && + ['pending', 'running', 'delivered'].includes(record.status)), + decision: record.decision, + decidedAt: record.decidedAt?.toISOString() ?? null, + completedAt: record.completedAt?.toISOString() ?? null, + detailsAvailable, + warning: record.warning === null ? null : truncateAtCodePoint(record.warning, 2048, ''), + account: account + ? { + credentialId: account.credentialId, + displayName: account.displayName, + providerId: account.providerId, + active: account.active, + } + : null, + fields: detailsAvailable ? assistantReviewFields(record) : [], + }, + ] + }) + return { receipts, nextCursor: records.length > 32 ? (page.at(-1)?.persistSeq ?? null) : null } + }, +}) diff --git a/apps/sim/lib/assistant-actions/application/review.ts b/apps/sim/lib/assistant-actions/application/review.ts index 97775bfaa65..6716e59c4c1 100644 --- a/apps/sim/lib/assistant-actions/application/review.ts +++ b/apps/sim/lib/assistant-actions/application/review.ts @@ -103,6 +103,7 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC const tool = getToolMetadata(record.executableName ?? '') return { toolCallId: record.toolCallId, + sequence: record.persistSeq, runId: record.runId, toolId: record.executableName ?? record.toolName, name: tool?.name ?? record.toolName, diff --git a/apps/sim/lib/assistant-actions/proposal.ts b/apps/sim/lib/assistant-actions/proposal.ts index abd006ab5aa..eb70e90a04c 100644 --- a/apps/sim/lib/assistant-actions/proposal.ts +++ b/apps/sim/lib/assistant-actions/proposal.ts @@ -57,7 +57,7 @@ export function applyAssistantReviewEdits( } /** Every supplied non-authentication argument remains visible, even without a custom review label. */ -export function assistantReviewFields(record: ActionRecord) { +export function assistantReviewFields(record: Pick) { const tool = getToolMetadata(record.executableName ?? '') const args = toRecord(record.args) const declared = tool?.assistant?.reviewFields ?? [] diff --git a/apps/sim/lib/mothership/chat/display-message.ts b/apps/sim/lib/mothership/chat/display-message.ts index 6454d94fc20..cd7cbb98c35 100644 --- a/apps/sim/lib/mothership/chat/display-message.ts +++ b/apps/sim/lib/mothership/chat/display-message.ts @@ -206,6 +206,7 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage { const display: ChatMessage = { id: msg.id, + ...(msg.streamId ? { streamId: msg.streamId } : {}), role: msg.role, ...(msg.origin ? { origin: msg.origin } : {}), content: msg.content, diff --git a/apps/sim/lib/mothership/chat/effective-transcript.ts b/apps/sim/lib/mothership/chat/effective-transcript.ts index 754ed08da1f..5e664ccffe7 100644 --- a/apps/sim/lib/mothership/chat/effective-transcript.ts +++ b/apps/sim/lib/mothership/chat/effective-transcript.ts @@ -549,6 +549,7 @@ export function buildLiveAssistantMessage(params: { return normalizeMessage({ id: getLiveAssistantMessageId(streamId), + streamId, role: 'assistant', content: runningText, timestamp: lastTimestamp ?? new Date().toISOString(), diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index a873e538ca4..915639411dc 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -127,12 +127,19 @@ export async function loadCopilotChatMessages( const rows = await db .select({ content: options?.projection === 'display' ? displayMessageContent : copilotMessages.content, + streamId: copilotMessages.streamId, }) .from(copilotMessages) .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) .orderBy(...TRANSCRIPT_ORDER) // Also strip on read: rows written before the backfill still carry outputs. - return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage)) + return rows.map((row) => + stripToolResultOutput( + options?.projection === 'display' + ? { ...(row.content as PersistedMessage), streamId: row.streamId ?? undefined } + : (row.content as PersistedMessage) + ) + ) } /** Messages in the newest window the chat UI opens on. */ @@ -178,9 +185,10 @@ async function loadCopilotChatTranscriptPage( createdAt: preciseTimestampText(copilotMessages.createdAt), role: copilotMessages.role, content: displayMessageContent, + streamId: copilotMessages.streamId, } - let rows: Array + let rows: Array let hasOlder: boolean if (window.kind === 'from') { const [page, older] = await Promise.all([ @@ -219,7 +227,12 @@ async function loadCopilotChatTranscriptPage( : window.position const windowStartCursor = windowStart ? encodeTranscriptCursor(windowStart) : null return { - messages: rows.map((row) => stripToolResultOutput(row.content as PersistedMessage)), + messages: rows.map((row) => + stripToolResultOutput({ + ...(row.content as PersistedMessage), + streamId: row.streamId ?? undefined, + }) + ), olderCursor: hasOlder ? windowStartCursor : null, windowStartCursor, } diff --git a/apps/sim/lib/mothership/chat/messages-store.ts b/apps/sim/lib/mothership/chat/messages-store.ts index 31468727f21..727af290c36 100644 --- a/apps/sim/lib/mothership/chat/messages-store.ts +++ b/apps/sim/lib/mothership/chat/messages-store.ts @@ -31,7 +31,8 @@ function toRow( options?: { chatModel?: string | null; streamId?: string | null } ): typeof copilotMessages.$inferInsert { const ts = new Date(message.timestamp) - const content = stripToolResultOutput(message) + const { streamId, ...storedMessage } = message + const content = stripToolResultOutput(storedMessage) return { chatId, messageId: message.id, diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index 761859ec9fb..b4fc382acb9 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -133,6 +133,8 @@ export function copyPersistedMessageContext(c: PersistedMessageContext): Persist export interface PersistedMessage { id: string + /** Server-owned turn association for display; never persisted in message content. */ + streamId?: string role: 'user' | 'assistant' requestMode?: 'agent' | 'assistant' | 'plan' content: string @@ -896,6 +898,7 @@ export function normalizeMessage(raw: Record): PersistedMessage if (raw.requestId && typeof raw.requestId === 'string') { msg.requestId = raw.requestId } + if (typeof raw.streamId === 'string') msg.streamId = raw.streamId const origin = TurnOrigin.safeParse(raw.origin) if (origin.success) msg.origin = origin.data diff --git a/apps/sim/scripts/test-assistant-actions-e2e.ts b/apps/sim/scripts/test-assistant-actions-e2e.ts index 3d8dde08afc..2457d8b8d07 100644 --- a/apps/sim/scripts/test-assistant-actions-e2e.ts +++ b/apps/sim/scripts/test-assistant-actions-e2e.ts @@ -7,13 +7,15 @@ import { createLogger } from '@sim/logger' import { createDeferred } from '@sim/testing/helpers/deferred' import { getErrorMessage } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' -import { toRecord } from '@sim/utils/object' +import { toArray, toRecord } from '@sim/utils/object' import { makeSignature } from 'better-auth/crypto' import postgres from 'postgres' import { + listAssistantActionReceiptsContract, listAssistantActionsContract, readAssistantToolPermissionsContract, } from '@/lib/api/contracts/assistant-actions' +import { getOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' import { closeRedisConnection } from '@/lib/core/config/redis' import { acquirePendingChatStream, @@ -59,6 +61,10 @@ const organizationId = generateId() const otherOrganizationId = generateId() const assistantId = generateId() const credentialId = generateId() +const secondCredentialId = generateId() +const codaServerId = generateId() +const firefliesServerId = generateId() +const firefliesCredentialId = `mcp-cg-${generateId()}` const nativeCredentialId = generateId() const groupId = generateId() const enrollmentId = generateId() @@ -73,6 +79,9 @@ const lagStreamId = generateId() const lagFirstCallId = generateId() const lagSecondCallId = generateId() const lateCallId = generateId() +const bulkChatId = generateId() +const bulkStreamId = generateId() +const bulkRunId = generateId() const reviewChatId = generateId() const reviewRunId = generateId() const reviewStreamId = generateId() @@ -87,6 +96,7 @@ const requestId = generateId() const firstCallId = generateId() const secondCallId = generateId() const fixtureStreams = [ + { chatId: bulkChatId, streamId: bulkStreamId }, { chatId: assistantId, streamId }, { chatId: lagChatId, streamId: lagStreamId }, { chatId: reviewChatId, streamId: reviewStreamId }, @@ -119,13 +129,18 @@ const answer = 'Review the two Jira issues before I create them.' const reviewAnswer = 'Review these keyboard and batch proposals.' const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] +const browserMessages: { type: string; text: string }[] = [] const browserErrors: string[] = [] const serverErrors: { path: string; status: number }[] = [] const httpResponses: { method: string; path: string; status: number }[] = [] const browser = await chromium.launch() const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) const page = await context.newPage() -const reviewCards = page.getByRole('region', { name: 'Review Jira Write', exact: true }) +const reviewCards = page.getByRole('region', { name: 'Review Create Jira issue', exact: true }) +page.on('console', (message) => { + if (browserMessages.length < 40) + browserMessages.push({ type: message.type(), text: message.text().slice(0, 1000) }) +}) page.on('pageerror', (error) => browserErrors.push(error.message)) page.on('response', (response) => { const url = new URL(response.url()) @@ -233,13 +248,37 @@ try { await tx`insert into session (id, token, user_id, expires_at, created_at, updated_at) values (${generateId()}, ${token}, ${userId}, now() + interval '1 day', now(), now())` await tx`insert into credential_group (id, organization_id, public_id, name, options) - values (${groupId}, ${organizationId}, ${generateId()}, 'Personal Jira', ${tx.json(['jira', 'salesforce', ...catalogAccounts.map((account) => account.option)].map((provider) => ({ id: provider, provider, label: provider, authorizationAppId: 'fixture', requiredScopes: [], scopeVersion: 1, required: false, status: 'active' })))})` + values (${groupId}, ${organizationId}, ${generateId()}, 'Personal Jira', ${tx.json([{ id: 'jira', provider: 'jira' }, { id: 'jira-team', provider: 'jira' }, { id: 'salesforce', provider: 'salesforce' }, ...catalogAccounts.map((account) => ({ id: account.option, provider: account.option }))].map(({ id, provider }) => ({ id, provider, label: id, authorizationAppId: 'fixture', requiredScopes: [], scopeVersion: 1, required: false, status: 'active' })))})` await tx`insert into resource_policy (id, organization_id, resource_type, resource_id, document, created_by, updated_by) values (${generateId()}, ${organizationId}, 'credential_group', ${groupId}, ${tx.json({ version: 2, resource: { type: 'credential_group', id: groupId }, statements: [] })}, ${userId}, ${userId})` await tx`insert into credential_group_enrollment (id, credential_group_id, user_id, email, status, invitation_token_hash, invitation_expires_at, invited_at) values (${enrollmentId}, ${groupId}, ${userId}, ${email}, 'completed', ${enrollmentId.replaceAll('-', '').repeat(2)}, now() + interval '1 day', now())` + for (const [id, provider] of [ + [codaServerId, 'coda'], + [firefliesServerId, 'fireflies'], + ] as const) { + await tx`insert into mcp_servers (id, organization_id, credential_group_id, managed_connector_id, name, transport, auth_type, url) + values (${id}, ${organizationId}, ${groupId}, ${provider}, ${provider}, 'streamable-http', 'oauth', 'https://example.invalid/mcp')` + } + await tx`insert into credential (id, organization_id, type, display_name, credential_group_enrollment_id, mcp_server_id, managed_oauth_status, encrypted_oauth_token_set, mcp_tools, granted_at) + values (${firefliesCredentialId}, ${organizationId}, 'managed_mcp', 'meetings@acme.example', ${enrollmentId}, ${firefliesServerId}, 'active', 'fixture-not-a-provider-token', '[]'::jsonb, now())` + for (const source of [ + 'coda', + 'jira', + 'gmail', + 'google_calendar', + 'google_drive', + 'confluence', + 'github', + 'google_meet', + ]) { + await tx`insert into organization_search_integration (organization_id, connector_type, approved) + values (${organizationId}, ${source}, true)` + } await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at) values (${credentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', 'jira', ${enrollmentId}, 'jira', 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())` + await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at) + values (${secondCredentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'launch-team@acme.example', 'jira', ${enrollmentId}, 'jira-team', 'active', 1, '{}'::text[], ${secondCredentialId}, 'fixture', 'fixture-not-a-provider-token', now())` await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at) values (${nativeCredentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', 'salesforce', ${enrollmentId}, 'salesforce', 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())` for (const account of catalogAccounts) { @@ -247,7 +286,8 @@ try { values (${account.credentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', ${account.provider}, ${enrollmentId}, ${account.option}, 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())` } await tx`insert into copilot_chats (id, user_id, organization_id, type, title, conversation_id, config) - values (${assistantId}, ${userId}, ${organizationId}, 'mothership', 'Launch follow-up', ${streamId}, ${tx.json({ conversationMode: 'assistant' })}), + values (${bulkChatId}, ${userId}, ${organizationId}, 'mothership', 'Paginated action review', ${bulkStreamId}, ${tx.json({ conversationMode: 'assistant' })}), + (${assistantId}, ${userId}, ${organizationId}, 'mothership', 'Launch follow-up', ${streamId}, ${tx.json({ conversationMode: 'assistant' })}), (${lagChatId}, ${userId}, ${organizationId}, 'mothership', 'Delayed action transcript', ${lagStreamId}, ${tx.json({ conversationMode: 'assistant' })}), (${reviewChatId}, ${userId}, ${organizationId}, 'mothership', 'Keyboard and batch review', ${reviewStreamId}, ${tx.json({ conversationMode: 'assistant' })})` const oldResponseId = generateId() @@ -286,12 +326,14 @@ try { values (${generateId()}, ${assistantId}, ${data.id}, ${data.role}, ${stream}, ${tx.json({ ...content, requestId, timestamp: new Date().toISOString() })}, ${index + 1})` } await tx`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq) - values (${generateId()}, ${lagChatId}, ${lagStreamId}, 'user', ${lagStreamId}, ${tx.json({ id: lagStreamId, role: 'user', content: 'Create two more Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1), + values (${generateId()}, ${bulkChatId}, ${bulkStreamId}, 'user', ${bulkStreamId}, ${tx.json({ id: bulkStreamId, role: 'user', content: 'Review this batch of Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1), + (${generateId()}, ${lagChatId}, ${lagStreamId}, 'user', ${lagStreamId}, ${tx.json({ id: lagStreamId, role: 'user', content: 'Create two more Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1), (${generateId()}, ${reviewChatId}, ${reviewStreamId}, 'user', ${reviewStreamId}, ${tx.json({ id: reviewStreamId, role: 'user', content: 'Prepare follow-up actions for review.', requestId, timestamp: new Date().toISOString() })}, 1)` await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status, started_at, completed_at, tool_admission_closed_at) values (${previousRunId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${previousStreamId}, 3, 'complete', now() - interval '1 hour', now() - interval '59 minutes', now() - interval '59 minutes')` await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status) - values (${runId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${streamId}, 3, 'paused_waiting_for_tool'), + values (${bulkRunId}, ${bulkChatId}, ${userId}, ${organizationId}, ${generateId()}, ${bulkStreamId}, 3, 'paused_waiting_for_tool'), + (${runId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${streamId}, 3, 'paused_waiting_for_tool'), (${lagRunId}, ${lagChatId}, ${userId}, ${organizationId}, ${generateId()}, ${lagStreamId}, 3, 'paused_waiting_for_tool'), (${reviewRunId}, ${reviewChatId}, ${userId}, ${organizationId}, ${generateId()}, ${reviewStreamId}, 3, 'paused_waiting_for_tool')` }) @@ -329,26 +371,207 @@ try { sameSite: 'Lax', }, ]) + await check( + 'Receipts restore actions before the transcript arrives and conceal foreign turns', + async () => { + const url = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions/receipts` + const response = await context.request.get(url, { params: { streamId } }) + assert.equal(response.status(), 200, await response.text()) + const result = listAssistantActionReceiptsContract.response.schema.parse( + await response.json() + ) + assert.deepEqual( + result.receipts.map((receipt) => receipt.toolCallId), + [firstCallId, secondCallId] + ) + assert(result.receipts.every((receipt) => receipt.state === 'pending')) + assert( + result.receipts.every((receipt) => + receipt.fields.every((field) => field.name !== 'credential') + ) + ) + const foreign = await context.request.get(url, { params: { streamId: lagStreamId } }) + assert.equal(foreign.status(), 200) + assert.deepEqual( + listAssistantActionReceiptsContract.response.schema.parse(await foreign.json()).receipts, + [] + ) + const overflow = new URL(url) + for (let i = 0; i < 33; i++) overflow.searchParams.append('toolCallIds', `call-${i}`) + assert.equal((await context.request.get(overflow.toString())).status(), 400) + } + ) + await check( + 'Historical receipts distinguish approval, completion, cancellation, and uncertain outcomes', + async () => { + await insertProposal(previousRunId, previousCallId, 'Original historical title') + await sql`update copilot_async_tool_calls set status = 'completed', permission_decision = 'allow', permission_decided_at = now(), approved_args = ${sql.json(proposalArguments('Approved historical title'))}, execution_started_at = now(), execution_settled_at = now(), completed_at = now(), result = ${sql.json({ output: { warning: 'Issue created; assignment needs attention. Do not recreate the issue.', privatePayload: 'not-for-the-receipt'.repeat(20_000) } })} where tool_call_id = ${previousCallId}` + const url = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions/receipts` + const response = await context.request.get(url, { params: { toolCallIds: previousCallId } }) + assert.equal(response.status(), 200, await response.text()) + const result = listAssistantActionReceiptsContract.response.schema.parse( + await response.json() + ) + const receipt = result.receipts[0] + assert(receipt) + assert.equal(receipt.state, 'succeeded') + assert.equal( + receipt.fields.find((field) => field.name === 'summary')?.value, + 'Approved historical title' + ) + assert.equal( + receipt.warning, + 'Issue created; assignment needs attention. Do not recreate the issue.' + ) + assert(!JSON.stringify(result).includes('privatePayload')) + const cases = [ + { + status: 'completed', + decision: 'allow', + started: true, + revoked: false, + unknown: true, + expected: 'uncertain', + }, + { + status: 'pending', + decision: 'skip', + started: false, + revoked: false, + unknown: false, + expected: 'cancelled', + }, + { + status: 'cancelled', + decision: 'allow', + started: true, + revoked: false, + unknown: false, + expected: 'uncertain', + }, + { + status: 'failed', + decision: 'allow', + started: true, + revoked: true, + unknown: true, + expected: 'uncertain', + }, + { + status: 'failed', + decision: 'allow', + started: true, + revoked: false, + unknown: false, + expected: 'failed', + }, + { + status: 'failed', + decision: 'allow', + started: false, + revoked: false, + unknown: false, + expected: 'failed', + }, + { + status: 'pending', + decision: 'allow', + started: false, + revoked: false, + unknown: false, + expected: 'cancelled', + }, + ] as const + for (const fixture of cases) { + const id = generateId() + await insertProposal(previousRunId, id, 'Historical outcome fixture') + await sql`update copilot_async_tool_calls set status = ${fixture.status}, permission_decision = ${fixture.decision}, execution_started_at = ${fixture.started ? new Date() : null}, execution_settled_at = ${fixture.started ? new Date() : null}, execution_revoked_at = ${fixture.revoked ? new Date() : null}, result = ${sql.json({ outcomeUnknown: fixture.unknown, notStarted: !fixture.started })} where tool_call_id = ${id}` + const stateResponse = await context.request.get(url, { params: { toolCallIds: id } }) + assert.equal(stateResponse.status(), 200) + const state = listAssistantActionReceiptsContract.response.schema.parse( + await stateResponse.json() + ).receipts[0] + assert.equal(state?.state, fixture.expected) + assert.equal(state?.unsettled, false) + await sql`delete from copilot_async_tool_calls where tool_call_id = ${id}` + } + await sql`update copilot_async_tool_calls set result = ${sql.json({ output: { warning: '😀'.repeat(1400) } })} where tool_call_id = ${previousCallId}` + const unicodeResponse = await context.request.get(url, { + params: { toolCallIds: previousCallId }, + }) + assert.equal(unicodeResponse.status(), 200) + assert.equal( + listAssistantActionReceiptsContract.response.schema.parse(await unicodeResponse.json()) + .receipts[0]?.warning, + '😀'.repeat(1024) + ) + await sql`update copilot_async_tool_calls set result = ${sql.json({ output: { warning: receipt.warning } })} where tool_call_id = ${previousCallId}` + const scoped = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/chats/${lagChatId}/actions/receipts`, + { params: { toolCallIds: previousCallId } } + ) + assert.equal(scoped.status(), 200) + assert.deepEqual( + listAssistantActionReceiptsContract.response.schema.parse(await scoped.json()).receipts, + [] + ) + } + ) + await check( + 'Display history carries the canonical turn identity and clears copied identities', + async () => { + for (const suffix of ['', '?window=latest']) { + const response = await context.request.get( + `${baseUrl.origin}/api/mothership/chats/${assistantId}${suffix}` + ) + assert.equal(response.status(), 200) + const messages = toArray(toRecord(toRecord(await response.json()).chat).messages).map( + toRecord + ) + assert.equal( + messages.find((message) => message.id === previousStreamId)?.streamId, + previousStreamId + ) + } + const copiedId = generateId() + await sql`insert into copilot_messages (id, chat_id, message_id, role, content, seq) values (${copiedId}, ${lagChatId}, ${copiedId}, 'assistant', ${sql.json({ id: copiedId, role: 'assistant', content: 'Copied history', timestamp: new Date().toISOString(), streamId: previousStreamId })}, 2)` + try { + const response = await context.request.get( + `${baseUrl.origin}/api/mothership/chats/${lagChatId}` + ) + assert.equal(response.status(), 200) + const messages = toArray(toRecord(toRecord(await response.json()).chat).messages).map( + toRecord + ) + const copied = messages.find((message) => message.id === copiedId) + assert(copied) + assert.equal(copied.streamId, undefined) + } finally { + await sql`delete from copilot_messages where id = ${copiedId}` + } + } + ) await check('A native Assistant account exposes its own tool permissions', async () => { await page.goto(`${baseUrl.origin}${root}/integrations`, { waitUntil: 'domcontentloaded', timeout: 180_000, }) - await page.getByRole('button', { name: 'Salesforce integration actions', exact: true }).click() - await expect(page.getByRole('menuitem', { name: 'Add account', exact: true })).toHaveCount(0) - await expect(page.getByRole('menuitem', { name: 'Reconnect', exact: true })).toBeVisible() - const permissions = page.locator( - `a[href="${root}/integrations/accounts/${nativeCredentialId}"]` - ) - await expect(permissions).toBeVisible({ timeout: 180_000 }) - await permissions.click() + await page.getByRole('link', { name: 'Open Salesforce integration', exact: true }).click() + await expect(page.getByRole('button', { name: 'Add account', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Reconnect', exact: true })).toBeVisible() + await capture('integration-account-actions') + await page.getByRole('button', { name: 'Disconnect', exact: true }).click() + await expect(page.getByRole('dialog', { name: 'Disconnect Salesforce' })).toBeVisible() + await capture('integration-disconnect-confirmation') + await page.getByRole('button', { name: 'Cancel', exact: true }).click() const policy = page.getByRole('radiogroup', { name: 'Create Task in Salesforce permission' }) await expect(policy).toBeVisible({ timeout: 180_000 }) await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked() + await capture('integration-detail-salesforce') }) - await check('Connected app menus agree with the real Assistant tool catalog', async () => { - await page.goto(`${baseUrl.origin}${root}/integrations`, { waitUntil: 'domcontentloaded' }) + await check('Integration details agree with the real Assistant tool catalog', async () => { for (const account of catalogAccounts) { + await page.goto(`${baseUrl.origin}${root}/integrations`, { waitUntil: 'domcontentloaded' }) const response = await context.request.get( `${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts/personal/${account.credentialId}/tool-permissions` ) @@ -356,26 +579,196 @@ try { const data = readAssistantToolPermissionsContract.response.schema.parse(await response.json()) assert.equal(data.account.credentialId, account.credentialId) assert.equal(data.tools.length > 0, account.supported, account.name) - const menu = page.getByRole('button', { - name: `${account.name} integration actions`, - exact: true, - }) - await expect(menu).toHaveCount(1) - await menu.click() - const permissions = page.getByRole('menu').locator('a[href*="/integrations/accounts/"]') - await expect(permissions).toHaveCount(account.supported ? 1 : 0) + await page + .getByRole('link', { name: `Open ${account.name} integration`, exact: true }) + .click() + const permissions = page.getByRole('textbox', { name: 'Search tools', exact: true }) if (account.supported) { - await expect(permissions).toHaveAttribute( - 'href', - `${root}/integrations/accounts/${account.credentialId}` - ) + await expect(permissions).toBeVisible() + await expect(page.getByRole('radiogroup').first()).toBeVisible() } else { await expect(permissions).toHaveCount(0) } - await page.keyboard.press('Escape') + await capture(`integration-detail-${account.name.toLowerCase().replaceAll(' ', '-')}`) } + await page.goto(`${baseUrl.origin}${root}/integrations`) + await expect( + page.getByRole('link', { name: 'Open Salesforce integration', exact: true }) + ).toBeVisible() await capture('integrations-desktop') }) + await check( + 'Multiple accounts isolate permissions and disconnect the selected account only', + async () => { + await page.goto(`${baseUrl.origin}${root}/integrations`) + await page.getByRole('link', { name: 'Open Jira integration', exact: true }).click() + await expect(page).toHaveURL((url) => url.searchParams.get('integration') === 'source:jira') + const accountsUrl = page.url() + const firstAccount = page.getByRole('link', { + name: 'Open taylor@acme.example account', + exact: true, + }) + const secondAccount = page.getByRole('link', { + name: 'Open launch-team@acme.example account', + exact: true, + }) + await expect(firstAccount).toBeVisible() + await expect(secondAccount).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Search tools', exact: true })).toHaveCount(0) + await capture('integration-multiple-accounts') + await expect( + page.getByRole('button', { name: 'Jira integration actions', exact: true }) + ).toHaveCount(0) + await page.setViewportSize({ width: 390, height: 844 }) + await capture('integration-multiple-accounts-mobile') + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)) + await page.setViewportSize({ width: 1440, height: 1000 }) + await secondAccount.click() + await expect(page).toHaveURL((url) => url.searchParams.get('account') === secondCredentialId) + const selectedUrl = page.url() + const policy = page.getByRole('radiogroup', { name: 'Jira Write permission' }) + await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked() + await capture('integration-selected-account') + await policy.getByRole('radio', { name: 'Block', exact: true }).click() + await expect(policy.getByRole('radio', { name: 'Block', exact: true })).toBeChecked() + await page.reload() + await expect(policy.getByRole('radio', { name: 'Block', exact: true })).toBeChecked() + for (const [id, expected] of [ + [credentialId, 'ask'], + [secondCredentialId, 'block'], + ] as const) { + const response = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts/personal/${id}/tool-permissions` + ) + assert.equal(response.status(), 200) + const permissions = readAssistantToolPermissionsContract.response.schema.parse( + await response.json() + ) + assert.equal( + permissions.tools.find((tool) => tool.toolId === 'jira_write')?.policy, + expected + ) + } + await page.getByRole('link', { name: 'Jira', exact: true }).click() + await expect(firstAccount).toBeVisible() + await firstAccount.click() + await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked() + await page.goto(selectedUrl) + await expect(page.getByRole('button', { name: 'Reconnect', exact: true })).toBeVisible() + await expect(policy.getByRole('radio', { name: 'Block', exact: true })).toBeChecked() + await capture('integration-selected-account-actions') + await page.getByRole('button', { name: 'Disconnect', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Disconnect Jira', exact: true }) + await expect(confirmation).toContainText('launch-team@acme.example') + await expect(confirmation).not.toContainText('taylor@acme.example') + await capture('integration-selected-account-disconnect') + const disconnected = page.waitForResponse( + (response) => + response.request().method() === 'DELETE' && + new URL(response.url()).pathname === + `/api/users/me/organization-accounts/${secondCredentialId}` + ) + await confirmation.getByRole('button', { name: 'Disconnect', exact: true }).click() + assert.equal((await disconnected).status(), 200) + await expect(confirmation).toBeHidden() + await expect( + page.getByText('This account is no longer connected.', { exact: true }) + ).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Search tools', exact: true })).toHaveCount(0) + const states = + await sql`select id, managed_oauth_status from credential where id in (${credentialId}, ${secondCredentialId})` + assert.equal( + states.find((account) => account.id === credentialId)?.managed_oauth_status, + 'active' + ) + assert.equal( + states.find((account) => account.id === secondCredentialId)?.managed_oauth_status, + 'revoked' + ) + await page.goto(accountsUrl) + await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked() + } + ) + await check( + 'Enabled, disabled and paused empty integrations preserve MCP account isolation', + async () => { + const inventoryResponse = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts` + ) + assert.equal(inventoryResponse.status(), 200) + const inventory = getOrganizationAccountsContract.response.schema.parse( + await inventoryResponse.json() + ) + assert( + inventory.viewerMcpAccounts?.some( + (account) => account.credentialId === firefliesCredentialId + ) + ) + await page.goto(`${baseUrl.origin}${root}/integrations`) + await page.getByRole('link', { name: 'Open Coda integration', exact: true }).click() + await expect(page).toHaveURL((url) => url.searchParams.get('integration') === 'source:coda') + const detailUrl = page.url() + const connect = page.getByRole('button', { name: 'Connect', exact: true }) + await expect(connect).toBeEnabled() + await expect(page.getByText('No account connected', { exact: true })).toBeVisible() + await expect(page.getByText('meetings@acme.example', { exact: true })).toHaveCount(0) + await capture('integration-enabled-empty') + const forgedUrl = new URL(detailUrl) + forgedUrl.searchParams.set('account', firefliesCredentialId) + await page.goto(forgedUrl.toString()) + await expect( + page.getByText('This account is no longer connected.', { exact: true }) + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Disconnect', exact: true })).toHaveCount(0) + await expect(page.getByRole('textbox', { name: 'Search tools', exact: true })).toHaveCount(0) + await sql`update organization_search_integration set approved = false where organization_id = ${organizationId} and connector_type = 'coda'` + try { + await page.goto(`${baseUrl.origin}${root}/integrations`) + await expect( + page.getByRole('link', { name: 'Open Coda integration', exact: true }) + ).toBeVisible() + await page.getByRole('link', { name: 'Open Coda integration', exact: true }).click() + await expect(page).toHaveURL((url) => url.searchParams.get('integration') === 'source:coda') + await expect(page.getByText('Disabled by your organization', { exact: true })).toBeVisible() + await expect(connect).toBeDisabled() + await capture('integration-disabled-empty') + } finally { + await sql`update organization_search_integration set approved = true where organization_id = ${organizationId} and connector_type = 'coda'` + } + await sql`update credential_group set status = 'disabled' where id = ${groupId}` + try { + await page.reload() + await expect( + page.getByText('Connections are paused by your organization', { exact: true }) + ).toBeVisible() + await expect(connect).toBeDisabled() + await capture('integration-paused-empty') + } finally { + await sql`update credential_group set status = 'active' where id = ${groupId}` + } + await sql`update credential set managed_oauth_status = 'revoked' where id = ${nativeCredentialId}` + await sql`update credential_group set options = (select jsonb_agg(case when value->>'id' = 'salesforce' then value || '{"status":"disabled"}'::jsonb else value end) from jsonb_array_elements(options)) where id = ${groupId}` + try { + await page.goto(`${baseUrl.origin}${root}/integrations`) + const salesforce = page.getByRole('link', { + name: 'Open Salesforce integration', + exact: true, + }) + await expect(salesforce).toBeVisible() + await salesforce.click() + await expect(page).toHaveURL( + (url) => url.searchParams.get('integration') === 'app:salesforce' + ) + await expect(page.getByText('No account connected', { exact: true })).toBeVisible() + await expect(page.getByText('Connections unavailable', { exact: true })).toBeVisible() + await expect(connect).toHaveCount(0) + await capture('integration-disabled-app-empty') + } finally { + await sql`update credential set managed_oauth_status = 'active' where id = ${nativeCredentialId}` + await sql`update credential_group set options = (select jsonb_agg(case when value->>'id' = 'salesforce' then value || '{"status":"active"}'::jsonb else value end) from jsonb_array_elements(options)) where id = ${groupId}` + } + } + ) await check('Personal permissions persist through the real settings route', async () => { await page.goto(`${baseUrl.origin}${root}/integrations/accounts/${credentialId}`, { waitUntil: 'domcontentloaded', @@ -402,6 +795,15 @@ try { 'A live Assistant turn restores exact inline actions without duplicating older rows', async () => { await page.setViewportSize({ width: 1440, height: 1000 }) + const reviewUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions` + await page.route(reviewUrl, async (route) => { + const response = await route.fetch() + const review = listAssistantActionsContract.response.schema.parse(await response.json()) + await route.fulfill({ + response, + json: { ...review, actions: review.actions.map(({ sequence, ...action }) => action) }, + }) + }) const streamResponse = page.waitForResponse( (response) => new URL(response.url()).pathname === '/api/mothership/chat/stream' && @@ -413,15 +815,35 @@ try { timeout: 180_000, }) assert.equal((await streamResponse).status(), 200) - await expect(reviewCards).toHaveCount(2, { timeout: 180_000 }) - await expect(page.getByText('Earlier Jira proposal', { exact: false })).toBeAttached() + const reviewResponse = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions` + ) + assert.equal(reviewResponse.status(), 200, await reviewResponse.text()) + const restoredReview = listAssistantActionsContract.response.schema.parse( + await reviewResponse.json() + ) + assert.equal(restoredReview.actions.length, 2) + await expect(reviewCards).toHaveCount(2) + await page.unroute(reviewUrl) + await expect( + page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: 'Approved historical title' }) + ).toBeAttached() await expect(page.getByRole('button', { name: 'Allow', exact: true })).toHaveCount(2) + await expect(reviewCards.getByRole('textbox')).toHaveCount(0) + await expect(reviewCards.getByText('Priority', { exact: true })).toHaveCount(0) + const previewBounds = await reviewCards.nth(0).boundingBox() + assert( + previewBounds && previewBounds.height < 310, + 'The default review should fit in a compact preview' + ) await expect( - reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true }) - ).toHaveValue('Publish the launch checklist') + reviewCards.nth(0).getByText('Publish the launch checklist', { exact: true }) + ).toBeVisible() await expect( - reviewCards.nth(1).getByRole('textbox', { name: 'Summary', exact: true }) - ).toHaveValue('Confirm the rollout owners') + reviewCards.nth(1).getByText('Confirm the rollout owners', { exact: true }) + ).toBeVisible() const prompt = await page.getByText(question, { exact: true }).elementHandle() assert(prompt, 'The current user turn must be restored') assert( @@ -456,11 +878,15 @@ try { await page.reload() await expect(reviewCards).toHaveCount(2) await expect( - reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true }) - ).toHaveValue('Publish the launch checklist') - await page.getByText(question, { exact: true }).scrollIntoViewIfNeeded() + reviewCards.nth(0).getByText('Publish the launch checklist', { exact: true }) + ).toBeVisible() + await expect( + page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: 'Approved historical title' }) + ).toBeAttached() + await reviewCards.last().scrollIntoViewIfNeeded() await capture('assistant-actions-desktop') - await reviewCards.nth(0).scrollIntoViewIfNeeded() await capture('action-review-desktop') await page.setViewportSize({ width: 390, height: 844 }) await reviewCards.nth(0).scrollIntoViewIfNeeded() @@ -502,10 +928,16 @@ try { await check( 'An edited approval and a cancellation affect only their saved proposals', async () => { + await reviewCards.nth(0).getByRole('button', { name: 'Edit', exact: true }).click() await reviewCards .nth(0) .getByRole('textbox', { name: 'Summary', exact: true }) .fill('Publish the reviewed launch checklist') + await capture('action-review-edit') + await reviewCards.nth(0).getByRole('button', { name: 'Done', exact: true }).click() + await expect( + reviewCards.nth(0).getByText('Publish the reviewed launch checklist', { exact: true }) + ).toBeVisible() const approval = page.waitForResponse( (response) => response.request().method() === 'PATCH' && @@ -516,12 +948,17 @@ try { const approvalResponse = await approval assert.equal(approvalResponse.status(), 200, await approvalResponse.text()) await expect(reviewCards).toHaveCount(1) - await expect(reviewCards.getByRole('textbox', { name: 'Summary', exact: true })).toHaveValue( - 'Confirm the rollout owners' - ) await expect( - page.getByText('No longer waiting for review', { exact: false }).first() + reviewCards.getByText('Confirm the rollout owners', { exact: true }) ).toBeVisible() + const approvedReceipt = page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: 'Publish the reviewed launch checklist' }) + await expect(approvedReceipt.getByText('Approved', { exact: true })).toBeVisible() + await approvedReceipt.getByRole('button', { name: 'View Create Jira issue receipt' }).click() + await expect(approvedReceipt.getByRole('textbox')).toHaveCount(0) + await expect(approvedReceipt.getByText('acme.atlassian.net', { exact: true })).toBeVisible() + await approvedReceipt.getByRole('button', { name: 'Hide Create Jira issue receipt' }).click() const cancellation = page.waitForResponse( (response) => response.request().method() === 'PATCH' && @@ -532,6 +969,16 @@ try { const cancellationResponse = await cancellation assert.equal(cancellationResponse.status(), 200, await cancellationResponse.text()) await expect(reviewCards).toHaveCount(0) + const cancelledReceipt = page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: 'Confirm the rollout owners' }) + await expect(cancelledReceipt.getByText('Cancelled', { exact: true })).toBeVisible() + await capture('action-receipts-mobile') + await page.setViewportSize({ width: 1440, height: 1000 }) + await capture('action-receipts-desktop') + await page.reload() + await expect(approvedReceipt.getByText('Approved', { exact: true })).toBeVisible() + await expect(cancelledReceipt.getByText('Cancelled', { exact: true })).toBeVisible() const rows = await sql`select tool_call_id, permission_decision, args, approved_args, execution_started_at from copilot_async_tool_calls where run_id = ${runId} order by persist_seq` assert.equal(rows[0]?.permission_decision, 'allow') @@ -568,7 +1015,7 @@ try { assert.deepEqual(initialData.actions, []) await insertProposal(lagRunId, lagFirstCallId, 'Review the support handoff') await insertProposal(lagRunId, lagSecondCallId, 'Prepare the launch announcement') - await page.route(`${reviewUrl}*`, (route) => + await page.route(reviewUrl, (route) => route.request().method() === 'GET' ? route.abort('failed') : route.continue() ) try { @@ -577,16 +1024,16 @@ try { }) await expect(reviewCards).toHaveCount(0) } finally { - await page.unroute(`${reviewUrl}*`) + await page.unroute(reviewUrl) } await page.getByRole('button', { name: 'Retry', exact: true }).click() await expect(reviewCards).toHaveCount(2) await expect( - reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true }) - ).toHaveValue('Review the support handoff') + reviewCards.nth(0).getByText('Review the support handoff', { exact: true }) + ).toBeVisible() await expect( - reviewCards.nth(1).getByRole('textbox', { name: 'Summary', exact: true }) - ).toHaveValue('Prepare the launch announcement') + reviewCards.nth(1).getByText('Prepare the launch announcement', { exact: true }) + ).toBeVisible() await appendStreamEvents(lagChatId, lagStreamId, [ pendingToolEvent(lagFirstCallId, 'Review the support handoff'), pendingToolEvent(lagSecondCallId, 'Prepare the launch announcement'), @@ -636,8 +1083,8 @@ try { ]) await expect(reviewCards).toHaveCount(1) await expect( - reviewCards.getByRole('textbox', { name: 'Summary', exact: true }) - ).toHaveValue('A later proposal that was never approved') + reviewCards.getByText('A later proposal that was never approved', { exact: true }) + ).toBeVisible() const cancellation = page.waitForResponse( (response) => response.request().method() === 'PATCH' && @@ -814,6 +1261,7 @@ try { } }) try { + await expect(reviewCards.getByRole('button', { name: 'Cancel', exact: true })).toBeEnabled() await reviewCards.getByRole('button', { name: 'Cancel', exact: true }).focus() await page.keyboard.press('Enter') await expect.poll(() => decisionPersisted).toBe(true) @@ -907,11 +1355,130 @@ try { ) assert.equal(response.status(), 404) }) + await check( + 'A 64-action recovery preserves sequence, pagination and receipts with bounded requests', + async () => { + const batchIds = Array.from({ length: 64 }, () => generateId()) + for (const [index, id] of batchIds.entries()) { + await insertProposal(bulkRunId, id, `Batch issue ${index + 1}`) + if (index % 2 === 0) { + await sql`update copilot_async_tool_calls set status = 'delivered', permission_decision = 'skip', permission_decided_at = now() where run_id = ${bulkRunId} and tool_call_id = ${id}` + } + } + await appendStreamEvents(bulkChatId, bulkStreamId, [ + { type: 'session', payload: { kind: 'start' } }, + { type: 'text', payload: { channel: 'assistant', text: 'Review the remaining actions.' } }, + ]) + const responsesBefore = httpResponses.length + await page.goto(`${baseUrl.origin}/o/${organizationId}/chat/${bulkChatId}?mode=assistant`) + const cards = page.locator( + 'section[aria-label="Review Create Jira issue"], section[aria-label="Create Jira issue receipt"]' + ) + await expect(cards).toHaveCount(48) + await expect(cards.nth(0)).toContainText('Batch issue 1') + await expect(cards.nth(0)).toContainText('Cancelled') + await expect(cards.nth(1)).toContainText('Batch issue 2') + const initialReceiptRequests = httpResponses + .slice(responsesBefore) + .filter((response) => response.path.endsWith('/actions/receipts')) + assert( + initialReceiptRequests.length <= 6, + `Recovery made ${initialReceiptRequests.length} receipt requests` + ) + await cards.nth(1).getByRole('button', { name: 'Allow', exact: true }).click() + await expect(cards.nth(1)).toContainText('Approved') + await expect(cards.nth(1)).toContainText('Batch issue 2') + await expect(cards.last()).toContainText('Batch issue 64') + await cards.last().getByRole('button', { name: 'Allow', exact: true }).click() + await expect(cards.last()).toContainText('Approved') + await expect(cards).toHaveCount(48) + await page.getByRole('button', { name: 'More action history', exact: true }).click() + await expect(cards).toHaveCount(64) + await page.reload() + await page.getByRole('button', { name: 'More action history', exact: true }).click() + await expect(cards).toHaveCount(64) + await expect(cards.nth(1)).toContainText('Approved') + await expect(cards.last()).toContainText('Approved') + await expect(cards.last()).toContainText('Batch issue 64') + await capture('approval-paginated-receipts') + const lastCallId = batchIds.at(-1) + const precedingCallId = batchIds.at(-3) + assert(lastCallId && precedingCallId) + await cards.nth(61).getByRole('button', { name: 'Allow', exact: true }).click() + await expect(cards.nth(61)).toContainText('Approved') + await sql`update copilot_async_tool_calls set status = 'completed', execution_started_at = now(), execution_settled_at = now(), completed_at = now(), result = ${sql.json({ success: true })} where run_id = ${bulkRunId} and tool_call_id = ${lastCallId}` + const receiptsUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${bulkChatId}/actions/receipts` + for (const [index, fixture] of [ + { id: lastCallId, summary: 'Batch issue 64', state: 'Completed' }, + { id: precedingCallId, summary: 'Batch issue 62', state: 'Approved' }, + ].entries()) { + const events = [pendingToolEvent(fixture.id, fixture.summary)] + if (fixture.state === 'Completed') + events.push({ + type: 'tool', + payload: { + phase: 'result', + executor: 'sim', + mode: 'async', + toolCallId: fixture.id, + toolName: 'jira_write', + success: true, + status: 'success', + output: { success: true }, + }, + }) + await appendStreamEvents(bulkChatId, bulkStreamId, events) + await page.route(`${receiptsUrl}*`, (route) => { + const ids = new URL(route.request().url()).searchParams.getAll('toolCallIds') + return ids.includes(fixture.id) ? route.abort('failed') : route.continue() + }) + try { + await page.reload() + await expect( + page.getByText('Could not load action history', { exact: true }) + ).toHaveCount(index + 1, { timeout: 15_000 }) + await capture(`receipt-history-${fixture.state.toLowerCase()}-retry`) + } finally { + await page.unroute(`${receiptsUrl}*`) + } + await page.getByRole('button', { name: 'Retry', exact: true }).first().click() + await expect( + page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: fixture.summary }) + ).toContainText(fixture.state) + } + } + ) + await check( + 'Automatically allowed writes wait for execution, not a second approval', + async () => { + const autoCallId = generateId() + await insertProposal(runId, autoCallId, 'Automatically allowed issue') + await sql`update copilot_async_tool_calls set permission_requested_at = null where run_id = ${runId} and tool_call_id = ${autoCallId}` + try { + const response = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions/receipts`, + { params: { toolCallIds: autoCallId } } + ) + assert.equal(response.status(), 200, await response.text()) + const result = listAssistantActionReceiptsContract.response.schema.parse( + await response.json() + ) + assert.equal(result.receipts[0]?.state, 'approved') + } finally { + await sql`delete from copilot_async_tool_calls where run_id = ${runId} and tool_call_id = ${autoCallId}` + } + } + ) await check('Browser runtime and HTTP responses', async () => { assert.deepEqual(browserErrors, []) assert.deepEqual(serverErrors, []) }) } catch (error) { + await writeFile(`${artifactDirectory}/failure.txt`, await page.locator('body').innerText()).catch( + () => undefined + ) logger.error('Assistant actions E2E failed', { error: getErrorMessage(error) }) await page.screenshot({ path: resolve(artifactDirectory, 'failure.png') }).catch(() => undefined) process.exitCode = 1 @@ -942,7 +1509,7 @@ try { await writeFile( reportPath, JSON.stringify( - { checks, browserErrors, serverErrors, httpResponses, artifactDirectory }, + { checks, browserErrors, browserMessages, serverErrors, httpResponses, artifactDirectory }, null, 2 ) diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts index 48b456d5e99..69c247772a6 100644 --- a/apps/sim/scripts/test-organization-search-e2e.ts +++ b/apps/sim/scripts/test-organization-search-e2e.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path' import { type BrowserContext, chromium, expect } from '@playwright/test' import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' import { createLogger } from '@sim/logger' +import { createDeferred } from '@sim/testing/helpers/deferred' import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' @@ -11,6 +12,8 @@ import { toRecord } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { makeSignature } from 'better-auth/crypto' import postgres from 'postgres' +import { searchWorkspaceKnowledgeContract } from '@/lib/api/contracts/knowledge/search' +import { type CreateEventInput, createEvent } from '@/lib/mothership/request/session/event' /** * Runs against a local app and disposable PostgreSQL with member Search and Access Control enabled. @@ -362,29 +365,49 @@ try { 'Search and Assistant preserve independent drafts across tabs and reloads', async () => { await page.goto(`${baseUrl.origin}${root}/search`) - await expect(page.getByRole('radio', { name: 'Search', exact: true })).toBeChecked() + await expect( + page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Search', exact: true }) + ).toHaveAttribute('aria-current', 'page') const searchInput = page.getByRole('textbox', { name: 'Search your sources' }) await searchInput.fill('Launch notes') - await page.getByRole('radio', { name: 'Assistant', exact: true }).click() + await page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Assistant', exact: true }) + .click() const assistantInput = page.getByRole('textbox', { name: 'Ask Sim' }) await expect(assistantInput).toHaveValue('') await assistantInput.fill('Summarize launch readiness') - await page.getByRole('radio', { name: 'Search', exact: true }).click() + await page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Search', exact: true }) + .click() await expect(searchInput).toHaveValue('Launch notes') - await page.getByRole('radio', { name: 'Assistant', exact: true }).click() + await page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Assistant', exact: true }) + .click() await expect(assistantInput).toHaveValue('Summarize launch readiness') await expect(page).toHaveURL( (url) => url.pathname === `${root}/search` && url.searchParams.get('mode') === 'assistant' ) await page.reload() - await expect(page.getByRole('radio', { name: 'Assistant', exact: true })).toBeChecked() + await expect( + page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Assistant', exact: true }) + ).toHaveAttribute('aria-current', 'page') await expect(assistantInput).toHaveValue('Summarize launch readiness') } ) await check( 'Traditional search submits the organization query and keeps results in Search', async () => { - await page.getByRole('radio', { name: 'Search', exact: true }).click() + await page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Search', exact: true }) + .click() const responsePromise = page.waitForResponse( (response) => new URL(response.url()).pathname === '/api/knowledge/search' && @@ -414,24 +437,299 @@ try { 'Summarize launch readiness' ) }) - await check('Saved assistant history opens inside Search and starts a fresh search', async () => { + await check('Changing Search views preserves the conversation, filters, and draft', async () => { + const path = `${root}/search/chat/${assistantId}` + await page.goto(`${baseUrl.origin}${path}?q=Launch&source=slack&updated=7d`) + const input = page.getByRole('textbox', { name: 'Ask Sim' }) + await input.fill('Which owners still need to reply?') + await page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Search', exact: true }) + .click() + await expect(page).toHaveURL( + (url) => url.pathname === path && url.searchParams.get('mode') === 'search' + ) + await page.reload() + await page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Assistant', exact: true }) + .click() + await expect(page.getByText(answer, { exact: true })).toBeVisible() + await expect(input).toHaveValue('Which owners still need to reply?') + const restored = new URL(page.url()) + assert.equal(restored.pathname, path) + assert.equal(restored.searchParams.get('q'), 'Launch') + assert.equal(restored.searchParams.get('source'), 'slack') + assert.equal(restored.searchParams.get('updated'), '7d') + }) + await check('New chat stays in Assistant and starts with a fresh draft', async () => { await page.goto(`${baseUrl.origin}${root}/search/chat/${assistantId}`) - await expect(page.getByRole('radio', { name: 'Assistant', exact: true })).toBeChecked() + await expect( + page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Assistant', exact: true }) + ).toHaveAttribute('aria-current', 'page') await expect(page.getByText(answer, { exact: true })).toBeVisible() await capture('assistant-conversation-desktop') - await page.getByRole('button', { name: 'New search', exact: true }).click() - await expect(page).toHaveURL(`${baseUrl.origin}${root}/search`) - await expect(page.getByRole('radio', { name: 'Search', exact: true })).toBeChecked() + await page.getByRole('button', { name: 'New chat', exact: true }).click() + await expect(page).toHaveURL(`${baseUrl.origin}${root}/search?mode=assistant`) + await expect(page.getByRole('textbox', { name: 'Ask Sim' })).toHaveValue('') + await expect( + page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: 'Assistant', exact: true }) + ).toHaveAttribute('aria-current', 'page') }) + await check( + 'Admission, New chat and repeated summaries preserve mode, context and drafts', + async () => { + const releaseAdmission = createDeferred() + let heldAdmission: Promise | undefined + let includeAdmissionHeader = true + const posts: Record[] = [] + const createdChatIds: string[] = [] + const chatEndpoint = `${baseUrl.origin}/api/mothership/chat` + const searchEndpoint = `${baseUrl.origin}/api/knowledge/search` + const documentId = generateId() + const searchRequests: Record[] = [] + const searchBody = searchWorkspaceKnowledgeContract.body + assert(searchBody) + await page.route(searchEndpoint, async (route) => { + const body = searchBody.parse(route.request().postDataJSON()) + searchRequests.push(body) + await route.fulfill({ + json: searchWorkspaceKnowledgeContract.response.schema.parse({ + success: true, + data: { + query: body.query, + retrieval: { status: 'complete', timedOutLegs: [] }, + results: [ + { + documentId, + knowledgeBaseId: generateId(), + knowledgeBaseName: 'Team documents', + documentName: 'Launch checklist', + sourceUrl: 'https://example.invalid/launch', + connectorType: 'slack', + sourceModifiedAt: new Date().toISOString(), + author: null, + content: 'The launch checklist is ready for review.', + chunkIndex: 0, + similarity: 1, + }, + ], + }, + }), + }) + }) + await page.route(chatEndpoint, async (route) => { + if (route.request().method() !== 'POST') return route.continue() + const body = toRecord(route.request().postDataJSON()) + const number = posts.push(body) + if (number === 1) await releaseAdmission.promise + await heldAdmission + const chatId = toStringOrNull(body.chatId) ?? generateId() + const streamId = toStringOrNull(body.userMessageId) + assert(streamId) + assert.equal(body.organizationId, organizationId) + assert.equal(body.mode, 'assistant') + createdChatIds.push(chatId) + const responseText = `Deterministic assistant response ${number}.` + const requestId = generateId() + await sql`insert into copilot_chats (id, user_id, organization_id, type, title, config) + values (${chatId}, ${userId}, ${organizationId}, 'mothership', 'Navigation test', ${sql.json({ conversationMode: 'assistant' })}) on conflict (id) do nothing` + for (const message of [ + { id: streamId, role: 'user', content: String(body.message) }, + { id: generateId(), role: 'assistant', content: responseText }, + ]) { + await sql`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq) + values (${generateId()}, ${chatId}, ${message.id}, ${message.role}, ${streamId}, ${sql.json({ ...message, requestId, timestamp: new Date().toISOString() })}, (select coalesce(max(seq), 0) + 1 from copilot_messages where chat_id = ${chatId}))` + } + const base = { chatId, streamId, requestId } + const events: CreateEventInput[] = [ + { ...base, cursor: '1', seq: 1, type: 'session', payload: { kind: 'chat', chatId } }, + { + ...base, + cursor: '2', + seq: 2, + type: 'text', + payload: { channel: 'assistant', text: responseText, textOffset: 0 }, + }, + { + ...base, + cursor: '3', + seq: 3, + type: 'complete', + payload: { status: 'complete', textLength: responseText.length }, + }, + ] + await route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/event-stream', + ...(includeAdmissionHeader ? { 'x-mothership-chat-id': chatId } : {}), + }, + body: `${events.map((event) => `data: ${JSON.stringify(createEvent(event))}\n\n`).join('')}data: [DONE]\n\n`, + }) + }) + try { + await page.goto(`${baseUrl.origin}${root}/search?q=Launch&source=slack&updated=7d`) + const navigation = page.getByRole('navigation', { name: 'Search views' }) + await navigation.getByRole('button', { name: 'Assistant', exact: true }).click() + const composer = page.getByRole('textbox', { name: 'Ask Sim' }) + await composer.fill('A new conversation before admission') + await composer.press('Enter') + await expect.poll(() => posts.length).toBe(1) + await page.goBack() + await expect( + navigation.getByRole('button', { name: 'Search', exact: true }) + ).toHaveAttribute('aria-current', 'page') + releaseAdmission.resolve() + await expect(page).toHaveURL( + (url) => + url.pathname === `${root}/search/chat/${createdChatIds[0]}` && + url.searchParams.get('mode') === 'search' + ) + assert.equal(new URL(page.url()).searchParams.get('q'), 'Launch') + assert.equal(new URL(page.url()).searchParams.get('source'), 'slack') + assert.equal(new URL(page.url()).searchParams.get('updated'), '7d') + await expect( + page.getByText('Deterministic assistant response 1.', { exact: true }) + ).not.toBeVisible() + await navigation.getByRole('button', { name: 'Assistant', exact: true }).click() + await expect( + page.getByText('Deterministic assistant response 1.', { exact: true }) + ).toBeVisible() + await composer.fill('An unsent follow-up') + await page.getByRole('button', { name: 'New chat', exact: true }).click() + await expect(page).toHaveURL(`${baseUrl.origin}${root}/search?mode=assistant`) + await expect(composer).toHaveValue('') + await composer.fill('A distinct new conversation') + await composer.press('Enter') + await expect( + page.getByText('Deterministic assistant response 2.', { exact: true }) + ).toBeVisible() + assert.equal(posts.length, 2) + assert.equal(posts[1]?.createNewChat, true) + assert.equal(posts[1]?.chatId, undefined) + assert.notEqual(createdChatIds[0], createdChatIds[1]) + await expect(page).toHaveURL( + (url) => url.pathname === `${root}/search/chat/${createdChatIds[1]}` + ) + await page.reload() + await expect( + page.getByText('Deterministic assistant response 2.', { exact: true }) + ).toBeVisible() + await expect( + page.getByText('Deterministic assistant response 1.', { exact: true }) + ).toHaveCount(0) + await capture('assistant-new-chat-after-admission') + const summaryStartedAt = Date.now() + await page.goto(`${baseUrl.origin}${root}/search?q=Launch&source=slack&updated=7d`) + await page + .getByRole('button', { name: 'Actions for Launch checklist', exact: true }) + .click() + await page.getByRole('menuitem', { name: 'Summarize', exact: true }).click() + await expect( + page.getByText('Deterministic assistant response 3.', { exact: true }) + ).toBeVisible() + const summaryChatId = createdChatIds[2] + assert.equal(posts[2]?.createNewChat, true) + assert.equal(posts[2]?.assistantSearchLevel, 'fast') + const { modifiedAfter: firstCutoff, ...firstSummaryFilters } = toRecord( + posts[2]?.assistantSearch + ) + const { modifiedAfter: searchCutoff, ...searchFilters } = toRecord( + searchRequests.at(-1)?.filters + ) + assert.equal(searchFilters.source, 'slack') + assert.deepEqual(firstSummaryFilters, { ...searchFilters, documentIds: [documentId] }) + assert.equal(typeof firstCutoff, 'string') + assert(Date.parse(String(firstCutoff)) >= summaryStartedAt - 7 * 24 * 60 * 60 * 1000) + assert(Date.parse(String(firstCutoff)) <= Date.now() - 7 * 24 * 60 * 60 * 1000) + await composer.fill('Keep this unsent draft') + await navigation.getByRole('button', { name: 'Search', exact: true }).click() + await page + .getByRole('button', { name: 'Actions for Launch checklist', exact: true }) + .click() + await page.getByRole('menuitem', { name: 'Summarize', exact: true }).click() + await expect( + page.getByText('Deterministic assistant response 4.', { exact: true }) + ).toBeVisible() + assert.equal(posts.length, 4) + assert.equal(posts[3]?.chatId, summaryChatId) + assert.equal(posts[3]?.createNewChat, false) + const { modifiedAfter: repeatedCutoff, ...repeatedSummaryFilters } = toRecord( + posts[3]?.assistantSearch + ) + assert.deepEqual(repeatedSummaryFilters, firstSummaryFilters) + assert.equal(typeof repeatedCutoff, 'string') + assert(Date.parse(String(repeatedCutoff)) >= Date.parse(String(firstCutoff))) + assert(Date.parse(String(repeatedCutoff)) <= Date.now() - 7 * 24 * 60 * 60 * 1000) + await expect(composer).toHaveValue('Keep this unsent draft') + await capture('assistant-repeat-summary-preserves-draft') + for (const selectedMode of ['search', 'assistant'] as const) { + includeAdmissionHeader = selectedMode === 'search' + await page.goto(`${baseUrl.origin}${root}/search?mode=assistant&q=Launch&source=slack`) + await composer.fill(`Preserve the pending ${selectedMode} selection`) + const admission = createDeferred() + heldAdmission = admission.promise + const requestNumber = posts.length + 1 + try { + await composer.press('Enter') + await expect.poll(() => posts.length).toBe(requestNumber) + if (selectedMode === 'assistant') { + await navigation.getByRole('button', { name: 'Search', exact: true }).click() + await expect(page).toHaveURL((url) => url.searchParams.get('mode') === 'search') + } + await page.clock.pauseAt(new Date(Date.now() + 100)) + const selected = navigation.getByRole('button', { + name: selectedMode === 'assistant' ? 'Assistant' : 'Search', + exact: true, + }) + await selected.click({ force: true }) + await expect(selected).toHaveAttribute('aria-current', 'page') + assert.notEqual(new URL(page.url()).searchParams.get('mode'), selectedMode) + admission.resolve() + await expect(page).toHaveURL( + (url) => + url.pathname === `${root}/search/chat/${createdChatIds.at(-1)}` && + url.searchParams.get('mode') === selectedMode + ) + assert.equal(new URL(page.url()).searchParams.get('source'), 'slack') + } finally { + admission.resolve() + heldAdmission = undefined + await page.clock.resume() + } + } + } finally { + releaseAdmission.resolve() + await page.unroute(chatEndpoint) + await page.unroute(searchEndpoint) + } + } + ) await check('Existing assistant bookmarks redirect into Search', async () => { await page.goto(`${baseUrl.origin}${root}/chat/${assistantId}`) await expect(page).toHaveURL(`${baseUrl.origin}${root}/search/chat/${assistantId}`) await expect(page.getByText(answer, { exact: true })).toBeVisible() }) + await check('New search clears its draft while Back restores the prior query draft', async () => { + await page.goto(`${baseUrl.origin}${root}/search?q=Launch`) + const input = page.getByRole('textbox', { name: 'Search your sources' }) + await input.fill('Edited launch query') + await page.getByRole('button', { name: 'New search', exact: true }).click() + await expect(page).toHaveURL(`${baseUrl.origin}${root}/search`) + await expect(input).toHaveValue('') + await page.goBack() + await expect(input).toHaveValue('Edited launch query') + assert.equal(new URL(page.url()).searchParams.get('q'), 'Launch') + }) await check('Search URLs cannot change an agent conversation into an assistant', async () => { await page.goto(`${baseUrl.origin}${root}/search/chat/${agentId}`) await expect(page).toHaveURL(`${baseUrl.origin}${root}/chat/${agentId}`) - await expect(page.getByRole('radiogroup', { name: 'Search mode' })).toHaveCount(0) + await expect(page.getByRole('navigation', { name: 'Search views' })).toHaveCount(0) }) await check('Assistant history rejects a chat from another organization', async () => { await page.goto(`${baseUrl.origin}${root}/search/chat/${deniedChatId}`) @@ -447,8 +745,10 @@ try { for (const mode of ['search', 'assistant'] as const) { await page.goto(`${baseUrl.origin}${root}/search?mode=${mode}`) await expect( - page.getByRole('radio', { name: mode === 'search' ? 'Search' : 'Assistant', exact: true }) - ).toBeChecked() + page + .getByRole('navigation', { name: 'Search views' }) + .getByRole('button', { name: mode === 'search' ? 'Search' : 'Assistant', exact: true }) + ).toHaveAttribute('aria-current', 'page') await expect( page.getByRole('textbox', { name: mode === 'search' ? 'Search your sources' : 'Ask Sim' }) ).toBeVisible() diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index fe4c7d33dea..df78587a75a 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1190,7 +1190,6 @@ "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx": 1, "apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx": 1, "apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx": 2, - "apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts": 1, "apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts": 2, "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx": 1, "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1,