- {/* Reserved even while empty so the field docks where the page header sits. */}
- {searching ? (
- <>
-
-
+
+
+
+ {!docked && (
+
+ Search {organization.name}
+
+ )}
+
- {/* The rows carry their own `px-2`; this gutter brings each row's mark under the
- field's own search glyph, so results read as a column hanging from the field. */}
-
-
-
-
- >
- ) : (
-
- {/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */}
-
-
- Search {organization.name}
-
-
- )}
+
)
}
diff --git a/apps/sim/app/sitemap.test.ts b/apps/sim/app/sitemap.test.ts
new file mode 100644
index 00000000000..924fc3bb948
--- /dev/null
+++ b/apps/sim/app/sitemap.test.ts
@@ -0,0 +1,54 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { ContentMeta } from '@/lib/content/schema'
+import sitemap from '@/app/sitemap'
+
+const { getLibraryPosts } = vi.hoisted(() => ({ getLibraryPosts: vi.fn() }))
+
+vi.mock('@/lib/blog/registry', () => ({ getAllPostMeta: async () => [] }))
+vi.mock('@/lib/library/registry', () => ({ getAllPostMeta: getLibraryPosts }))
+vi.mock('@/lib/customers/registry', () => ({ getAllCustomerStoryMeta: async () => [] }))
+vi.mock('@/lib/core/utils/urls', () => ({ SITE_URL: 'https://example.com' }))
+
+function post(slug: string, updated: string): ContentMeta {
+ const author = { id: 'sim', name: 'Sim' }
+ return {
+ slug,
+ title: slug,
+ description: 'A library article',
+ date: '2026-01-01',
+ updated,
+ author,
+ authors: [author],
+ tags: [],
+ ogImage: '/cover.png',
+ canonical: 'https://example.com/library/canonical-article',
+ draft: false,
+ featured: false,
+ technical: false,
+ }
+}
+
+describe('sitemap canonical URLs', () => {
+ it.each([false, true])(
+ 'emits one entry with the latest modification date (reversed=%s)',
+ async (reverse) => {
+ const posts = [post('original', '2026-01-02'), post('alias', '2026-02-03')]
+ getLibraryPosts.mockResolvedValue(reverse ? posts.reverse() : posts)
+
+ const pages = await sitemap()
+
+ expect(new Set(pages.map((page) => page.url)).size).toBe(pages.length)
+ expect(pages.filter((page) => page.url === posts[0].canonical)).toEqual([
+ { url: posts[0].canonical, lastModified: new Date('2026-02-03') },
+ ])
+ expect(pages).toContainEqual({ url: 'https://example.com/workflows' })
+ expect(pages).toContainEqual({
+ url: 'https://example.com/library/authors/sim',
+ lastModified: new Date('2026-02-03'),
+ })
+ }
+ )
+})
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index dc78158068a..bf5f33e639e 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -221,7 +221,7 @@ export default async function sitemap(): Promise
{
})),
]
- return [
+ const pages: MetadataRoute.Sitemap = [
...staticPages,
...blogPages,
...authorPages,
@@ -233,4 +233,17 @@ export default async function sitemap(): Promise {
...modelEntries,
...comparisonPages,
]
+
+ const canonicalPages = new Map()
+ for (const page of pages) {
+ const existing = canonicalPages.get(page.url)
+ if (
+ !existing ||
+ (page.lastModified &&
+ (!existing.lastModified || new Date(page.lastModified) > new Date(existing.lastModified)))
+ ) {
+ canonicalPages.set(page.url, page)
+ }
+ }
+ return [...canonicalPages.values()]
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
index 2264db73f56..c4a857f3c7d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx
@@ -87,6 +87,9 @@ describe('incomplete search coverage', () => {
it.each([false, true])(
'distinguishes incomplete retrieval and permits retry (hasResults=%s)',
async (hasResults) => {
+ mocks.overview.mockReturnValue({
+ data: { providers: [{ connectorType: 'gmail', isSyncing: true }] },
+ })
mocks.search.mockReturnValue({
data: {
query: 'launch',
@@ -118,9 +121,10 @@ describe('incomplete search coverage', () => {
expect(container.textContent).not.toContain('Search couldn’t run')
expect(container.textContent).not.toContain('No documents')
expect(container.textContent).toContain(
- hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.'
+ hasResults ? '1 document · some results may be missing.' : 'Search timed out.'
)
expect(container.textContent).not.toContain('Search found no results.')
+ expect(container.textContent).not.toContain('Still indexing')
if (hasResults) expect(container.textContent).toContain('Release plan')
const retry = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Try again'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
index b4bff7f98dd..dfce603fb86 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { type ReactNode, useState } from 'react'
import { Chip, ChipLink, cn } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import { ActivityStatus } from '@/components/ui/activity-status'
@@ -94,6 +94,8 @@ type KnowledgeSearchResultsProps = (
| { scope: ResourceScope; workspaceId?: never }
) & {
query: string
+ /** Lets the page dock its header after this query has displayed results. */
+ renderLayout?: (results: ReactNode, hasDisplayedResults: boolean) => ReactNode
/** Binds the Assistant turn to the selected canonical document. */
onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void
}
@@ -104,6 +106,7 @@ export function KnowledgeSearchResults({
scope: suppliedScope,
query,
onSummarize,
+ renderLayout,
}: KnowledgeSearchResultsProps) {
const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! }
const { data: session } = useSession()
@@ -114,6 +117,7 @@ export function KnowledgeSearchResults({
scope={scope}
query={trimmed}
onSummarize={onSummarize}
+ renderLayout={renderLayout}
/>
)
}
@@ -122,9 +126,11 @@ interface SearchResultsProps {
scope: ResourceScope
query: string
onSummarize: KnowledgeSearchResultsProps['onSummarize']
+ renderLayout: KnowledgeSearchResultsProps['renderLayout']
}
-function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
+function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResultsProps) {
+ const [hasDisplayedResults, setHasDisplayedResults] = useState(false)
const [searchedAt] = useState(Date.now)
const {
data: index,
@@ -168,28 +174,28 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
const partial = search?.retrieval.status === 'partial'
const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents`
- if (noSources) {
- return (
-
-
No sources are set up yet.
-
- View sources
-
-
- )
- }
const indexingNote =
indexing.length > 0
? `Still indexing ${indexing.join(', ')}; results grow as documents land.`
: null
- return (
+ const showResults = !noSources && !failed && !basesPending && documents.length > 0
+ if (showResults && !hasDisplayedResults) setHasDisplayedResults(true)
+
+ const content = noSources ? (
+
+
No sources are set up yet.
+
+ View sources
+
+
+ ) : (
@@ -201,14 +207,14 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
? 'Search couldn’t run.'
: partial
? documents.length === 0
- ? 'Search didn’t finish.'
+ ? 'Search timed out.'
: `${documentCount} · some results may be missing.`
: documents.length === 0
? 'Search found no results.'
: `${documentCount} · searched as you`}
)}
- {indexingNote && !failed && (
+ {indexingNote && !failed && !partial && (
{indexingNote}
)}
@@ -259,7 +265,7 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) {
))}
- {!failed && !basesPending && documents.length > 0 && (
+ {showResults && (
)
+ return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx
index f6f22427549..d5c2d33f965 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx
@@ -15,6 +15,19 @@ vi.mock('@/lib/auth/auth-client', () => ({
useSession: () => ({ data: { user: { id: mocks.userId } } }),
}))
vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request }))
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => '/o/organization/search',
+}))
+vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
+ useOrganizationContext: () => ({
+ organization: { id: 'organization', name: 'Acme' },
+ searchAccess: { memberScoped: true },
+ }),
+}))
+vi.mock('@/hooks/use-speech-to-text', () => ({
+ useSpeechToText: () => ({ isSupported: false }),
+}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }),
useSearchSourceOverview: () => ({
@@ -56,6 +69,7 @@ import type {
WorkspaceKnowledgeSearchData,
} from '@/lib/api/contracts/knowledge'
import type { ResourceScope } from '@/lib/core/resource-scope'
+import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results'
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
@@ -102,16 +116,22 @@ async function render({
scope = { kind: 'organization', organizationId: 'organization' },
query = 'launch',
params = '',
+ organizationPage = false,
}: {
scope?: ResourceScope
query?: string
params?: string
+ organizationPage?: boolean
} = {}) {
await act(async () => {
root.render(
-
+ {organizationPage ? (
+
+ ) : (
+
+ )}
)
@@ -170,6 +190,69 @@ async function complete(
}
describe('search refinement with the real query cache and URL state', () => {
+ it('keeps the organization header docked while source and date changes run filtered searches', async () => {
+ await render({ organizationPage: true, params: '?q=launch' })
+ expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
+ await complete(0)
+ expect(container.querySelector('h1')).toBeNull()
+ const input = container.querySelector('input')
+ const filters = container.querySelector('[aria-label="Search filters"]')
+
+ for (const [label, expectedFilters] of [
+ ['Gmail', { source: 'gmail' }],
+ ['Past week', { source: 'gmail', modifiedAfter: '2026-01-08T12:00:00.000Z' }],
+ ['Past month', { source: 'gmail', modifiedAfter: '2025-12-16T12:00:00.000Z' }],
+ ] as const) {
+ const previousRequests = requests.length
+ const control = button(label)
+ await click(label)
+ expect(requests).toHaveLength(previousRequests + 1)
+ expect(requests.at(-1)?.body).toEqual({
+ organizationId: 'organization',
+ query: 'launch',
+ filters: expectedFilters,
+ })
+ expect(container.querySelector('h1')).toBeNull()
+ expect(container.querySelector('input')).toBe(input)
+ expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters)
+ expect(document.activeElement).toBe(control)
+ expect(container.textContent).toContain('Updating results…')
+ expect(
+ container.querySelector('[aria-label="Search results"]')?.getAttribute('aria-busy')
+ ).toBe('true')
+ expect(container.querySelector('a[data-source-link]')).not.toBeNull()
+ await complete(previousRequests, { title: `${label} result` })
+ expect(container.querySelector('a[data-source-link]')?.textContent).toBe(`${label} result`)
+ expect(document.activeElement).toBe(control)
+ expect(container.querySelector('h1')).toBeNull()
+ }
+ })
+
+ it.each(['empty', 'timeout', 'error'] as const)(
+ 'keeps the organization header docked when a refinement returns %s',
+ async (outcome) => {
+ await render({ organizationPage: true, params: '?q=launch' })
+ await complete(0)
+ const gmail = button('Gmail')
+ await click('Gmail')
+ if (outcome === 'error') {
+ await act(async () => {
+ requests[1].reject(new Error('Search failed'))
+ await vi.advanceTimersByTimeAsync(1)
+ })
+ } else {
+ await complete(1, { empty: true, partial: outcome === 'timeout' })
+ }
+ expect(container.querySelector('h1')).toBeNull()
+ expect(button('Gmail')).toBe(gmail)
+ expect(document.activeElement).toBe(gmail)
+ expect(container.textContent).not.toContain('Release plan')
+ await click('All sources')
+ expect(container.textContent).toContain('Release plan')
+ expect(container.querySelector('h1')).toBeNull()
+ }
+ )
+
it('replaces filter URL state while preserving unrelated parameters', async () => {
await render({ params: '?q=launch&panel=details' })
await click('Gmail')
@@ -302,7 +385,7 @@ describe('search refinement with the real query cache and URL state', () => {
await render()
await complete(0, { partial: true, empty })
expect(container.textContent).toContain(
- empty ? 'Search didn’t finish.' : 'some results may be missing.'
+ empty ? 'Search timed out.' : 'some results may be missing.'
)
expect(container.textContent).not.toContain('Search found no results.')
const gmail = button('Gmail')
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
index dedae46db01..351a5a46b72 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx
@@ -127,16 +127,14 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a
advance(100)
render([tool('first', 'success'), tool('second')])
render([tool('first', 'success'), tool('second', status)])
- const prefix =
+ const label =
status === 'error' || status === 'rejected'
- ? 'Failed'
- : status === 'skipped'
- ? 'Skipped'
- : 'Stopped'
- expect(header()?.textContent).toBe(`${prefix} reading second`)
+ ? 'Reading second'
+ : `${status === 'skipped' ? 'Skipped' : 'Stopped'} reading second`
+ expect(header()?.textContent).toBe(label)
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
advance(1500)
- expect(header()?.textContent).toBe(`${prefix} reading second`)
+ expect(header()?.textContent).toBe(label)
}
)
@@ -176,15 +174,51 @@ describe.each(['mothership', 'workflow', 'browser', 'deploy'])('%s activity', (a
const trigger = container.querySelector
('[role="button"]')!
act(() => trigger.click())
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
- 'Failed reading firstReading second'
+ 'Reading firstReading second'
)
render([tool('first', 'error'), tool('second', 'success')], false)
expect(header()?.textContent).toBe('Read files')
expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
- 'Failed reading firstRead second'
+ 'Reading firstRead second'
)
})
+ it.each(['error', 'rejected'] as const)(
+ 'keeps a %s model description neutral in the header and expanded history',
+ (status) => {
+ render(
+ [
+ {
+ ...tool('first', status),
+ displayTitle: 'Failed reading reference material',
+ activityDescription: 'Locating reference material',
+ },
+ ],
+ false
+ )
+ expect(header()?.textContent).toBe('Locating reference material')
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ render(
+ [
+ {
+ ...tool('first', status),
+ displayTitle: 'Failed reading reference material',
+ activityDescription: 'Failed: Locating reference material',
+ },
+ tool('second', 'success'),
+ ],
+ false
+ )
+ const trigger = container.querySelector('[role="button"]')!
+ act(() => trigger.click())
+ expect(container.querySelector('[data-state="open"]')?.textContent).toBe(
+ 'Locating reference materialRead second'
+ )
+ expect(container.textContent).not.toContain('Failed')
+ expect(container.querySelector('[class*="shimmer"]')).toBeNull()
+ }
+ )
+
it('shows three distinct actions and keeps the complete history available', () => {
render(
[
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
index 1b1075a60ce..39fb385c1e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts
@@ -162,10 +162,10 @@ describe('AgentGroup inline main activity', () => {
it.each([
['executing', 'Reading notes'],
['success', 'Read notes'],
- ['error', 'Failed reading notes'],
+ ['error', 'Reading notes'],
['cancelled', 'Stopped reading notes'],
['skipped', 'Skipped reading notes'],
- ['rejected', 'Failed reading notes'],
+ ['rejected', 'Reading notes'],
['interrupted', 'Stopped reading notes'],
] as const)('renders a single %s tool once without a disclosure', (status, expected) => {
act(() =>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
index 53f44524306..f14ae20a0e2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.ts
@@ -89,7 +89,7 @@ describe('getToolActivitySummary', () => {
})
it.each([
- ['rejected', 'Failed running checks'],
+ ['rejected', 'Running checks'],
['skipped', 'Skipped running checks'],
['interrupted', 'Stopped running checks'],
] as const)('labels a single %s tool as finished', (status, expected) => {
@@ -132,9 +132,9 @@ describe('getToolActivitySummary', () => {
).toBe('Read files, searched files, used the terminal +1 more · 1 stopped · 1 skipped')
})
- it('keeps individual failures explicit without adding aggregate failure badges', () => {
+ it('keeps individual unsuccessful actions neutral without aggregate failure badges', () => {
const rejected = { ...tool('terminal', 'rejected'), displayTitle: 'Running checks' }
- expect(getToolActivitySummary([rejected])).toBe('Failed running checks')
+ expect(getToolActivitySummary([rejected])).toBe('Running checks')
expect(getToolActivitySummary([rejected, tool('read', 'skipped')])).toBe(
'Tool activity · 1 skipped'
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
index 1692702faec..7f750517922 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx
@@ -53,9 +53,9 @@ describe('ToolCallItem', () => {
it.each([
['executing', 'Checking the invoice totals'],
['success', 'Checked the invoice totals'],
- ['error', 'Failed checking the invoice totals'],
+ ['error', 'Checking the invoice totals'],
['cancelled', 'Stopped checking the invoice totals'],
- ['rejected', 'Failed checking the invoice totals'],
+ ['rejected', 'Checking the invoice totals'],
['skipped', 'Skipped checking the invoice totals'],
] as const)(
'projects %s from the actual tool status onto the model description',
@@ -130,7 +130,7 @@ describe('ToolCallItem', () => {
/>
)
- expect(markup).toContain('Failed checking invoices')
+ expect(markup).toContain('Checking invoices')
expect(markup).not.toContain('Stopped checking invoices')
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
index 19543772721..a32fd27dae7 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
@@ -676,9 +676,9 @@ describe('completed tool titles', () => {
expect(failures).toEqual([])
})
- it('keeps present tense while executing; failed rows say so', () => {
+ it('keeps the action description for executing and unsuccessful rows', () => {
expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs')
- expect(firstToolTitle([queryLogsCall('error')])).toBe('Failed querying logs')
+ expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs')
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index 1e17cf742fb..71f94b3b37b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -481,7 +481,7 @@ export function ResourceTabs({
if (isMultiDrag) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected))
- const dragImage = buildMultiDragImage(e.currentTarget.closest('[role="tablist"]'), selected)
+ const dragImage = buildMultiDragImage(e.currentTarget.closest('[data-tab-strip]'), selected)
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 16, 16)
dragImageRef.current = dragImage
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.test.tsx
new file mode 100644
index 00000000000..7effe3be58c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.test.tsx
@@ -0,0 +1,150 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ enabled: false, toggle: vi.fn() }))
+vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
+vi.mock('@/hooks/queries/inbox', () => ({
+ useInboxConfig: () => ({ data: { enabled: mocks.enabled, address: 'inbox@example.com' } }),
+ useToggleInbox: () => useMutation({ mutationFn: mocks.toggle }),
+}))
+vi.mock('@sim/emcn', () => ({
+ Label: ({ children }: { children: ReactNode }) => {children} ,
+ ChipSwitch: ({ onChange }: { onChange: (value: string) => void }) => (
+ <>
+ onChange('enabled')}>
+ On
+
+ onChange('disabled')}>
+ Off
+
+ >
+ ),
+ ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? : null,
+ ChipModalHeader: ({ children }: { children: ReactNode }) => {children} ,
+ ChipModalBody: ({ children }: { children: ReactNode }) => {children}
,
+ ChipModalField: () => null,
+ ChipModalError: ({ children }: { children: ReactNode }) =>
+ children ? {children}
: null,
+ ChipModalFooter: ({
+ onCancel,
+ primaryAction,
+ }: {
+ onCancel: () => void
+ primaryAction: { label: string; onClick: () => void }
+ }) => (
+ <>
+
+ Cancel
+
+
+ {primaryAction.label}
+
+ >
+ ),
+ ChipConfirmModal: ({
+ open,
+ children,
+ onOpenChange,
+ confirm,
+ }: {
+ open: boolean
+ children: ReactNode
+ onOpenChange: (open: boolean) => void
+ confirm: { label: string; onClick: () => void }
+ }) =>
+ open ? (
+
+ {children}
+ onOpenChange(false)}>
+ Cancel
+
+
+ {confirm.label}
+
+
+ ) : null,
+}))
+
+import { InboxEnableToggle } from '@/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle'
+
+let container: HTMLDivElement
+let root: Root
+let client: QueryClient
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+ mocks.enabled = false
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
+})
+afterEach(() => {
+ act(() => root.unmount())
+ client.clear()
+ container.remove()
+ vi.useRealTimers()
+})
+
+function render() {
+ act(() =>
+ root.render(
+
+
+
+ )
+ )
+}
+async function click(label: string) {
+ const button = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === label
+ )
+ expect(button).toBeDefined()
+ await act(async () => {
+ button?.click()
+ await vi.runAllTimersAsync()
+ })
+}
+
+describe('inbox setup error visibility', () => {
+ it.each([
+ { enabled: false, toggle: 'On', submit: 'Enable' },
+ { enabled: true, toggle: 'Off', submit: 'Disable inbox' },
+ ])(
+ 'keeps the dialog open and displays a failed $submit request',
+ async ({ enabled, toggle, submit }) => {
+ mocks.enabled = enabled
+ mocks.toggle.mockRejectedValueOnce(new Error('Email service unavailable'))
+ render()
+ await click(toggle)
+ await click(submit)
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Email service unavailable'
+ )
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull()
+ await click('Cancel')
+ await click(toggle)
+ expect(container.querySelector('[role="alert"]')).toBeNull()
+ }
+ )
+
+ it('clears the failure and closes after a successful retry', async () => {
+ mocks.toggle
+ .mockRejectedValueOnce(new Error('Email service unavailable'))
+ .mockResolvedValueOnce({ enabled: true })
+ render()
+ await click('On')
+ await click('Enable')
+ expect(container.querySelector('[role="alert"]')).not.toBeNull()
+ await click('Enable')
+ expect(container.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.toggle).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
index 0159c6ac807..41c87b24cf0 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
@@ -1,21 +1,25 @@
'use client'
-import { useCallback, useState } from 'react'
+import { useState } from 'react'
import {
ChipConfirmModal,
ChipModal,
ChipModalBody,
+ ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ ChipSwitch,
Label,
- Switch,
} from '@sim/emcn'
-import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox'
-const logger = createLogger('InboxEnableToggle')
+const INBOX_OPTIONS = [
+ { value: 'enabled', label: 'On' },
+ { value: 'disabled', label: 'Off' },
+] as const
export function InboxEnableToggle() {
const params = useParams()
@@ -28,56 +32,68 @@ export function InboxEnableToggle() {
const [isDisableOpen, setIsDisableOpen] = useState(false)
const [enableUsername, setEnableUsername] = useState('')
- const handleToggle = useCallback(async (checked: boolean) => {
+ function handleToggle(checked: boolean) {
+ toggleInbox.reset()
if (checked) {
setIsEnableOpen(true)
- return
+ } else {
+ setIsDisableOpen(true)
}
- setIsDisableOpen(true)
- }, [])
+ }
- const handleDisable = useCallback(async () => {
- try {
- await toggleInbox.mutateAsync({ workspaceId, enabled: false })
- setIsDisableOpen(false)
- } catch (error) {
- logger.error('Failed to disable inbox', { error })
- }
- }, [workspaceId, toggleInbox.mutateAsync])
+ function handleEnableOpenChange(open: boolean) {
+ if (!toggleInbox.isPending) setIsEnableOpen(open)
+ }
- const handleEnable = useCallback(async () => {
- try {
- await toggleInbox.mutateAsync({
- workspaceId,
- enabled: true,
- username: enableUsername.trim() || undefined,
- })
- setIsEnableOpen(false)
- setEnableUsername('')
- } catch (error) {
- logger.error('Failed to enable inbox', { error })
- }
- }, [workspaceId, enableUsername, toggleInbox.mutateAsync])
+ function handleDisable() {
+ toggleInbox.mutate(
+ { workspaceId, enabled: false },
+ { onSuccess: () => setIsDisableOpen(false) }
+ )
+ }
+
+ function handleEnable() {
+ toggleInbox.mutate(
+ { workspaceId, enabled: true, username: enableUsername.trim() || undefined },
+ {
+ onSuccess: () => {
+ setIsEnableOpen(false)
+ setEnableUsername('')
+ },
+ }
+ )
+ }
+
+ const error = toggleInbox.error
+ ? getErrorMessage(toggleInbox.error, 'Failed to update inbox')
+ : null
return (
<>
-
Enable email inbox
+
Enable email inbox
Allow this workspace to receive tasks via email
-
handleToggle(value === 'enabled')}
disabled={toggleInbox.isPending}
/>
-
- setIsEnableOpen(false)}>Enable email inbox
+
+ handleEnableOpenChange(false)}>
+ Enable email inbox
+
An email address will be created for this workspace. Anyone in the allowed senders list
@@ -93,11 +109,13 @@ export function InboxEnableToggle() {
Leave blank for an auto-generated address.
+ {error}
setIsEnableOpen(false)}
+ onCancel={() => handleEnableOpenChange(false)}
+ cancelDisabled={toggleInbox.isPending}
primaryAction={{
- label: 'Enable',
+ label: toggleInbox.isPending ? 'Enabling...' : 'Enable',
onClick: handleEnable,
disabled: toggleInbox.isPending,
}}
@@ -125,6 +143,7 @@ export function InboxEnableToggle() {
Your existing conversations and task history will be preserved.
+ {error}
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
index 894a55ef643..b6274cde49d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
@@ -296,6 +296,11 @@ export function InboxSettingsTab() {
)}
+ {updateSecretPolicy.error && (
+
+ {getErrorMessage(updateSecretPolicy.error, 'Failed to update secret access')}
+
+ )}
diff --git a/apps/sim/background/knowledge-processing.test.ts b/apps/sim/background/knowledge-processing.test.ts
index 2623409ec68..b200f9a0dbd 100644
--- a/apps/sim/background/knowledge-processing.test.ts
+++ b/apps/sim/background/knowledge-processing.test.ts
@@ -34,7 +34,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
}))
import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error'
-import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
+import { EmbeddingAPIError } from '@/lib/embeddings/api-error'
+import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client'
import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit'
import {
OcrRequestRejectedError,
diff --git a/apps/sim/lib/billing/core/inbox-entitlement.test.ts b/apps/sim/lib/billing/core/inbox-entitlement.test.ts
new file mode 100644
index 00000000000..38073f0b69e
--- /dev/null
+++ b/apps/sim/lib/billing/core/inbox-entitlement.test.ts
@@ -0,0 +1,203 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ dbChainMockFns,
+ resetDbChainMock,
+ resetEnvFlagsMock,
+ resetEnvMock,
+ setEnv,
+ setEnvFlags,
+} from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetPersonalSubscription,
+ mockGetOrganizationSubscription,
+ mockGetWorkspaceWithOwner,
+ mockGetEffectiveBillingStatus,
+ mockIsOrganizationBillingBlocked,
+} = vi.hoisted(() => ({
+ mockGetPersonalSubscription: vi.fn(),
+ mockGetOrganizationSubscription: vi.fn(),
+ mockGetWorkspaceWithOwner: vi.fn(),
+ mockGetEffectiveBillingStatus: vi.fn(),
+ mockIsOrganizationBillingBlocked: vi.fn(),
+}))
+
+vi.mock('@/lib/billing/core/plan', () => ({
+ getHighestPriorityPersonalSubscription: mockGetPersonalSubscription,
+ getHighestPrioritySubscription: vi.fn(),
+}))
+
+vi.mock('@/lib/billing/core/billing', () => ({
+ getOrganizationSubscription: mockGetOrganizationSubscription,
+}))
+
+vi.mock('@/lib/billing/core/access', () => ({
+ getEffectiveBillingStatus: mockGetEffectiveBillingStatus,
+ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
+}))
+
+import {
+ hasWorkspaceInboxAccess,
+ hasWorkspaceInboxGraceAccess,
+} from '@/lib/billing/core/subscription'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ setEnv({ COPILOT_API_KEY: 'test-copilot-key' })
+ setEnvFlags({ isHosted: true, isBillingEnabled: true, isInboxEnabled: false })
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: null,
+ })
+ mockGetPersonalSubscription.mockResolvedValue(null)
+ mockGetOrganizationSubscription.mockResolvedValue(null)
+ mockGetEffectiveBillingStatus.mockResolvedValue({
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ })
+ mockIsOrganizationBillingBlocked.mockResolvedValue(false)
+})
+
+afterEach(() => {
+ resetEnvFlagsMock()
+ resetEnvMock()
+})
+
+describe('Sim Mailer hosted entitlement', () => {
+ it.each([
+ { isInboxEnabled: true, isBillingEnabled: true },
+ { isInboxEnabled: false, isBillingEnabled: false },
+ { isInboxEnabled: true, isBillingEnabled: false },
+ ])('requires a qualifying payer despite deployment flags %o', async (flags) => {
+ setEnvFlags(flags)
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(false)
+ expect(mockGetPersonalSubscription).toHaveBeenCalledWith('payer-1')
+ })
+
+ it.each([
+ ['pro_25000', true],
+ ['enterprise', true],
+ ['pro_6000', false],
+ ['pro', false],
+ ['free', false],
+ ])('checks the personal workspace payer plan %s', async (plan, expected) => {
+ mockGetPersonalSubscription.mockResolvedValue({ plan, status: 'active' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(expected)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(expected)
+ })
+
+ it.each([
+ ['team_25000', true],
+ ['enterprise', true],
+ ['team_6000', false],
+ ])(
+ 'checks the organization payer plan %s without requiring a personal plan',
+ async (plan, expected) => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: 'org-1',
+ })
+ dbChainMockFns.limit.mockResolvedValueOnce([{ plan, status: 'active' }])
+ mockGetOrganizationSubscription.mockResolvedValue({ plan, status: 'active' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(expected)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(expected)
+ expect(mockGetPersonalSubscription).not.toHaveBeenCalled()
+ expect(mockGetOrganizationSubscription).toHaveBeenCalledWith('org-1', { onError: 'throw' })
+ }
+ )
+
+ it('blocks a past-due Max payer from use while preserving provisioned resources', async () => {
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'past_due' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('blocks a billing-blocked Max payer from use while preserving provisioned resources', async () => {
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'active' })
+ mockGetEffectiveBillingStatus.mockResolvedValue({ billingBlocked: true })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('requires the execution key for use without destroying resources when it is missing', async () => {
+ setEnv({ COPILOT_API_KEY: undefined })
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'active' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+})
+
+describe('Sim Mailer cleanup uncertainty', () => {
+ it('preserves the inbox if the workspace cannot be found', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue(null)
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('preserves the inbox on a workspace lookup failure', async () => {
+ mockGetWorkspaceWithOwner.mockRejectedValue(new Error('Database unavailable'))
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('requires the personal subscription reader to surface errors and preserves the inbox', async () => {
+ mockGetPersonalSubscription.mockRejectedValue(new Error('Database unavailable'))
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ expect(mockGetPersonalSubscription).toHaveBeenCalledWith('payer-1', { onError: 'throw' })
+ })
+
+ it('requires the organization subscription reader to surface errors and preserves the inbox', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: 'org-1',
+ })
+ mockGetOrganizationSubscription.mockRejectedValue(new Error('Database unavailable'))
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ expect(mockGetOrganizationSubscription).toHaveBeenCalledWith('org-1', { onError: 'throw' })
+ })
+
+ it('retains past-due Max for Teams resources', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: 'org-1',
+ })
+ mockGetOrganizationSubscription.mockResolvedValue({ plan: 'team_25000', status: 'past_due' })
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+})
+
+describe('Sim Mailer self-hosted overrides', () => {
+ it.each([
+ { isInboxEnabled: true, isBillingEnabled: true },
+ { isInboxEnabled: false, isBillingEnabled: false },
+ ])('preserves self-hosted configuration %o', async (flags) => {
+ setEnvFlags({ isHosted: false, ...flags })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(true)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index 10b709b54bd..64c6b0c8f6d 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -803,16 +803,14 @@ const hasMaxTierWorkspaceAccess = cache(
* Inbox.
*
* Otherwise returns true if:
- * - INBOX_ENABLED env var is set (self-hosted override), OR
- * - billing is disabled, OR
+ * - on self-hosted deployments, INBOX_ENABLED is set or billing is disabled, OR
* - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR
* - the billed user has an individual Max/enterprise subscription (personal workspace).
*/
export async function hasWorkspaceInboxAccess(workspaceId: string): Promise
{
try {
if (!env.COPILOT_API_KEY) return false
- if (isInboxEnabled) return true
- if (!isBillingEnabled) return true
+ if (!isHosted && (isInboxEnabled || !isBillingEnabled)) return true
return await hasMaxTierWorkspaceAccess(workspaceId)
} catch (error) {
logger.error('Error checking workspace inbox access', { error, workspaceId })
@@ -834,12 +832,12 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise {
try {
- if (isInboxEnabled) return true
- if (!isBillingEnabled) return true
+ if (!isHosted && (isInboxEnabled || !isBillingEnabled)) return true
return await hasWorkspaceTierAccess(workspaceId, isMaxTier, {
intent: 'retention',
onMissingWorkspace: true,
+ onError: 'throw',
})
} catch (error) {
logger.error('Error checking workspace inbox grace access', { error, workspaceId })
diff --git a/apps/sim/lib/consent/scripts.test.ts b/apps/sim/lib/consent/scripts.test.ts
index 48477954a39..a43c11045f7 100644
--- a/apps/sim/lib/consent/scripts.test.ts
+++ b/apps/sim/lib/consent/scripts.test.ts
@@ -7,7 +7,6 @@ import {
GLOBAL_CONSENT_SCRIPTS,
GOOGLE_ADS_ID,
GOOGLE_ANALYTICS_ID,
- HUBSPOT_SCRIPT,
X_PIXEL_SCRIPT,
} from '@/lib/consent/scripts'
@@ -27,7 +26,6 @@ const CALLBACK_INFO: ConsentScriptCallbackInfo = {
afterEach(() => {
window.dataLayer = []
window.gtag = undefined
- window._hsq = []
window.history.replaceState({}, '', '/')
})
@@ -49,8 +47,7 @@ describe('consent scripts', () => {
])
})
- it('keeps landing vendors in separate consent categories', () => {
- expect(HUBSPOT_SCRIPT).toMatchObject({ id: 'hubspot', category: 'measurement' })
+ it('gates the landing conversion pixel on marketing consent', () => {
expect(X_PIXEL_SCRIPT).toMatchObject({
id: 'x-pixel',
category: 'marketing',
@@ -86,13 +83,4 @@ describe('consent scripts', () => {
`https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ADS_ID}`
)
})
-
- it('gives HubSpot a query-free path before its automatic first page view', () => {
- window.history.replaceState({}, '', '/demo?email=private@example.com#booking')
- window._hsq = []
-
- HUBSPOT_SCRIPT.onBeforeLoad()
-
- expect(window._hsq).toEqual([['setPath', '/demo']])
- })
})
diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts
index c431bec29cc..b797f580a56 100644
--- a/apps/sim/lib/consent/scripts.ts
+++ b/apps/sim/lib/consent/scripts.ts
@@ -23,12 +23,6 @@ export const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn' as const
const AHREFS_ANALYTICS_KEY = 'WJ9yWTBAiQKZAE/2TyU/yA' as const
-declare global {
- interface Window {
- _hsq?: unknown[][]
- }
-}
-
const GOOGLE_ANALYTICS_SCRIPT = gtag({
id: GOOGLE_ANALYTICS_ID,
category: 'measurement',
@@ -70,15 +64,3 @@ export const GLOBAL_CONSENT_SCRIPTS = [
/** Marketing-page integrations that should not load on a direct workspace visit. */
export const X_PIXEL_SCRIPT = xPixel({ pixelId: X_PIXEL_ID })
-
-/** HubSpot has no first-party c15t helper, so it uses the generic script contract. */
-export const HUBSPOT_SCRIPT = {
- id: 'hubspot',
- src: 'https://js-na2.hs-scripts.com/246720681.js',
- category: 'measurement',
- async: true,
- onBeforeLoad: () => {
- window._hsq ||= []
- window._hsq.push(['setPath', window.location.pathname])
- },
-} as const
diff --git a/apps/sim/lib/content/seo.ts b/apps/sim/lib/content/seo.ts
index 0dea5ea335e..61e407461f6 100644
--- a/apps/sim/lib/content/seo.ts
+++ b/apps/sim/lib/content/seo.ts
@@ -242,7 +242,7 @@ export function buildTagsMetadata(section: ContentSection): Metadata {
const canonical = `${SITE_URL}${section.basePath}/tags`
const description = `Browse Sim ${section.name.toLowerCase()} posts by topic: AI agents, workflows, integrations, and more.`
return {
- title: 'Tags',
+ title: `${section.name} Tags`,
description,
alternates: { canonical },
openGraph: {
diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts
index 525be693b82..41900a665ab 100644
--- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts
+++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
import {
+ type CompiledDocReadOptions,
loadCompiledDoc,
loadPublishedCompiledDoc,
publishCompiledDocArtifact,
@@ -736,7 +737,7 @@ export async function loadCompiledDocByExt(
workspaceId: string,
source: string,
ext: string,
- options: {
+ options: CompiledDocReadOptions & {
allowLegacyReferencedArtifact?: boolean
allowPublishedReferencedArtifact?: boolean
filePrincipal?: Principal
@@ -744,18 +745,24 @@ export async function loadCompiledDocByExt(
): Promise<{ buffer: Buffer; contentType: string } | null> {
const fmt = await getE2BDocFormat(`x.${ext}`)
if (!fmt) return null
+ const readOptions: CompiledDocReadOptions = { maxBytes: options.maxBytes, signal: options.signal }
const referencedFileIds = collectReferencedFileIds(source)
if (!options.filePrincipal) {
if (referencedFileIds.size === 0) {
- const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
+ const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
return buffer ? { buffer, contentType: fmt.contentType } : null
}
if (options.allowPublishedReferencedArtifact) {
- const publishedBuffer = await loadPublishedCompiledDoc(workspaceId, source, fmt.ext)
+ const publishedBuffer = await loadPublishedCompiledDoc(
+ workspaceId,
+ source,
+ fmt.ext,
+ readOptions
+ )
if (publishedBuffer) return { buffer: publishedBuffer, contentType: fmt.contentType }
}
if (!options.allowLegacyReferencedArtifact) return null
- const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
+ const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null
}
const referencedImages = await resolveReferencedImages(
@@ -768,11 +775,12 @@ export async function loadCompiledDocByExt(
workspaceId,
source,
fmt.ext,
- referencedImages.artifactIdentity
+ referencedImages.artifactIdentity,
+ readOptions
)
if (buffer) return { buffer, contentType: fmt.contentType }
if (referencedImages.artifactIdentity && options.allowLegacyReferencedArtifact) {
- const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
+ const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
if (legacyBuffer) return { buffer: legacyBuffer, contentType: fmt.contentType }
}
return null
@@ -799,7 +807,8 @@ export type ServableDoc =
export async function resolveServableDoc(
workspaceId: string,
storedBytes: Buffer,
- fileName: string
+ fileName: string,
+ options: CompiledDocReadOptions = {}
): Promise {
const fmt = await getE2BDocFormat(fileName)
if (!fmt) return { kind: 'passthrough' }
@@ -810,7 +819,7 @@ export async function resolveServableDoc(
workspaceId,
storedBytes.toString('utf-8'),
fmt.ext,
- { allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
+ { ...options, allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
)
return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' }
} catch (error) {
diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts
index 5fbaa13dbfb..c7a1ad258e8 100644
--- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts
+++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts
@@ -3,19 +3,18 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockDownloadFile, mockHeadObject, mockUploadFile } = vi.hoisted(() => ({
+const { mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({
mockDownloadFile: vi.fn(),
- mockHeadObject: vi.fn(),
mockUploadFile: vi.fn(),
}))
vi.mock('@/lib/uploads/core/storage-service', () => ({
downloadFile: mockDownloadFile,
- headObject: mockHeadObject,
uploadFile: mockUploadFile,
}))
import {
+ loadCompiledDoc,
loadPublishedCompiledDoc,
storeCompiledDoc,
} from '@/lib/copilot/tools/server/files/doc-compiled-store'
@@ -25,7 +24,10 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
describe('compiled document publication', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockHeadObject.mockResolvedValue(null)
+ mockDownloadFile.mockReset()
+ mockDownloadFile.mockRejectedValue(
+ Object.assign(new Error('Missing object'), { code: 'NoSuchKey' })
+ )
})
it('publishes a source-keyed pointer after storing a dependency-bound artifact', async () => {
@@ -53,7 +55,6 @@ describe('compiled document publication', () => {
})
it('loads only the exact dependency-bound artifact named by the published pointer', async () => {
- mockHeadObject.mockResolvedValue({ size: 1 })
mockDownloadFile
.mockResolvedValueOnce(
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
@@ -70,7 +71,6 @@ describe('compiled document publication', () => {
})
it('bounds the artifact read so an oversized artifact is never materialized', async () => {
- mockHeadObject.mockResolvedValue({ size: 1 })
mockDownloadFile.mockResolvedValueOnce(
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
)
@@ -88,7 +88,6 @@ describe('compiled document publication', () => {
it('surfaces an oversized artifact instead of reporting it as not yet built', async () => {
// `null` means "still compiling", which callers answer with a retry — an artifact
// that is too large would sit behind that answer forever.
- mockHeadObject.mockResolvedValue({ size: 1 })
mockDownloadFile.mockResolvedValueOnce(
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
)
@@ -105,8 +104,61 @@ describe('compiled document publication', () => {
)
})
+ it('applies the caller budget and cancellation to both pointer and artifact downloads', async () => {
+ const signal = new AbortController().signal
+ const maxBytes = 25 * 1024 * 1024
+ mockDownloadFile
+ .mockResolvedValueOnce(
+ Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'x'.repeat(8192) }))
+ )
+ .mockResolvedValueOnce(Buffer.from('%PDF-artifact'))
+
+ await loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', { maxBytes, signal })
+
+ expect(mockDownloadFile).toHaveBeenCalledTimes(2)
+ for (const [options] of mockDownloadFile.mock.calls) {
+ expect(options).toMatchObject({ maxBytes, signal })
+ }
+ })
+
+ it('cancels a pointer read without an uncancellable metadata preflight', async () => {
+ const controller = new AbortController()
+ mockDownloadFile.mockImplementationOnce(async ({ signal }) => {
+ expect(signal).toBe(controller.signal)
+ controller.abort()
+ signal.throwIfAborted()
+ })
+ await expect(
+ loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', {
+ signal: controller.signal,
+ })
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ })
+ it.each(['NoSuchKey', 'BlobNotFound', 'ENOENT', 404])(
+ 'returns null for a missing pointer (%s)',
+ async (code) => {
+ mockDownloadFile.mockRejectedValueOnce(Object.assign(new Error('Missing'), { code }))
+ await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).resolves.toBeNull()
+ }
+ )
+ it('propagates pointer permission errors', async () => {
+ mockDownloadFile.mockRejectedValueOnce(new Error('Access denied'))
+ await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow(
+ 'Access denied'
+ )
+ })
+ it('does not turn an interrupted artifact download into a cache miss', async () => {
+ const controller = new AbortController()
+ mockDownloadFile.mockImplementationOnce(async () => {
+ controller.abort()
+ throw new Error('download interrupted')
+ })
+ await expect(
+ loadCompiledDoc('workspace-1', 'source', 'pdf', undefined, { signal: controller.signal })
+ ).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
it('still reports a missing artifact as not yet built', async () => {
- mockHeadObject.mockResolvedValue({ size: 1 })
mockDownloadFile.mockResolvedValueOnce(
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
)
@@ -118,7 +170,6 @@ describe('compiled document publication', () => {
})
it('fails fast on a malformed published pointer', async () => {
- mockHeadObject.mockResolvedValue({ size: 1 })
mockDownloadFile.mockResolvedValueOnce(Buffer.from('{not-json'))
await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow(
diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts
index a35cb3f10b2..e98342a01e1 100644
--- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts
+++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
-import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service'
+import { isObjectNotFoundError } from '@/lib/uploads/core/errors'
+import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
const logger = createLogger('CopilotDocCompiledStore')
@@ -37,15 +38,37 @@ function publishedArtifactPointerKey(workspaceId: string, source: string, ext: s
return `copilot-doc-compiled/${workspaceId}/${sourceHash}.${ext}.published.json`
}
+export interface CompiledDocReadOptions {
+ maxBytes?: number
+ signal?: AbortSignal
+}
+
interface PublishedArtifactPointer {
version: 1
referencedInputIdentity: string
}
-async function loadPublishedArtifactPointer(key: string): Promise {
- const stored = await headObject(key, 'copilot')
- if (!stored) return null
- const encoded = await downloadFile({ key, context: 'copilot' })
+async function loadPublishedArtifactPointer(
+ key: string,
+ options: CompiledDocReadOptions = {}
+): Promise {
+ options.signal?.throwIfAborted()
+ let encoded: Buffer
+ try {
+ encoded = await downloadFile({
+ key,
+ context: 'copilot',
+ maxBytes: Math.min(
+ options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES,
+ MAX_BUFFERED_TRANSFER_BYTES
+ ),
+ signal: options.signal,
+ })
+ } catch (error) {
+ options.signal?.throwIfAborted()
+ if (isObjectNotFoundError(error)) return null
+ throw error
+ }
let decoded: unknown
try {
@@ -75,11 +98,8 @@ async function loadPublishedArtifactPointer(key: string): Promise {
const key = compiledArtifactKey(workspaceId, source, ext, referencedInputIdentity)
try {
- return await downloadFile({ key, context: 'copilot', maxBytes: MAX_BUFFERED_TRANSFER_BYTES })
+ return await downloadFile({
+ key,
+ context: 'copilot',
+ maxBytes: Math.min(
+ options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES,
+ MAX_BUFFERED_TRANSFER_BYTES
+ ),
+ signal: options.signal,
+ })
} catch (error) {
+ options.signal?.throwIfAborted()
if (isPayloadSizeLimitError(error)) throw error
return null
}
@@ -140,12 +170,19 @@ export async function publishCompiledDocArtifact(
export async function loadPublishedCompiledDoc(
workspaceId: string,
source: string,
- ext: string
+ ext: string,
+ options: CompiledDocReadOptions = {}
): Promise {
const key = publishedArtifactPointerKey(workspaceId, source, ext)
- const pointer = await loadPublishedArtifactPointer(key)
+ const pointer = await loadPublishedArtifactPointer(key, options)
if (!pointer) return null
- const artifact = await loadCompiledDoc(workspaceId, source, ext, pointer.referencedInputIdentity)
+ const artifact = await loadCompiledDoc(
+ workspaceId,
+ source,
+ ext,
+ pointer.referencedInputIdentity,
+ options
+ )
if (!artifact) throw new Error(`Published compiled document artifact is missing: ${key}`)
return artifact
}
diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts
index 336ebe43491..87432887d62 100644
--- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts
+++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts
@@ -96,7 +96,9 @@ describe('resolveServableDocBytes', () => {
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
WORKSPACE_ID,
PDF_SOURCE.toString('utf-8'),
- 'pdf'
+ 'pdf',
+ undefined,
+ { maxBytes: undefined, signal: undefined }
)
expect(mockLoadCompiledDoc).toHaveBeenCalledTimes(1)
})
@@ -123,7 +125,9 @@ describe('resolveServableDocBytes', () => {
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
WORKSPACE_ID,
PDF_SOURCE.toString('utf-8'),
- 'pdf'
+ 'pdf',
+ undefined,
+ { maxBytes: undefined, signal: undefined }
)
})
@@ -202,7 +206,13 @@ describe('resolveServableDocBytes', () => {
buffer: legacyArtifact,
contentType: 'application/pdf',
})
- expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf')
+ expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
+ WORKSPACE_ID,
+ source.toString('utf-8'),
+ 'pdf',
+ undefined,
+ { maxBytes: undefined, signal: undefined }
+ )
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
expect(mockStoreCompiledDoc).not.toHaveBeenCalled()
@@ -221,7 +231,8 @@ describe('resolveServableDocBytes', () => {
expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith(
WORKSPACE_ID,
source.toString('utf-8'),
- 'pdf'
+ 'pdf',
+ { maxBytes: undefined, signal: undefined }
)
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
@@ -242,7 +253,8 @@ describe('resolveServableDocBytes', () => {
expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith(
WORKSPACE_ID,
source.toString('utf-8'),
- 'pdf'
+ 'pdf',
+ { maxBytes: undefined, signal: undefined }
)
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
})
@@ -307,7 +319,13 @@ describe('resolveServableDocBytes', () => {
contentType: 'application/pdf',
})
expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled()
- expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf')
+ expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
+ WORKSPACE_ID,
+ source.toString('utf-8'),
+ 'pdf',
+ undefined,
+ { maxBytes: undefined, signal: undefined }
+ )
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
})
diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts
index 39739a99d3e..bede6ec4b2e 100644
--- a/apps/sim/lib/copilot/tools/tool-display.test.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.test.ts
@@ -200,26 +200,19 @@ describe('getToolCompletedTitle', () => {
expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined()
})
- it('projects a terminal tense for every settled row, present tense only while running', () => {
+ it('keeps unsuccessful actions neutral without rewriting them as completed', () => {
expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows')
expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe(
'Comparing workflows'
)
- // An errored row must not read as still running — the frozen present-tense
- // title ("Searching for X" forever) was reported as a stuck tool call.
- expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe(
- 'Failed comparing workflows'
- )
+ expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows')
expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe(
- 'Failed searching for admin mentions'
+ 'Searching for admin mentions'
)
expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe(
'Stopped comparing workflows'
)
- // Non-gerund titles get a prefix rather than a bad rewrite.
- expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe(
- 'Failed: Read recent emails'
- )
+ expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe('Read recent emails')
})
})
@@ -688,11 +681,28 @@ describe('terminal-title projection is idempotent', () => {
expect(getToolStatusDisplayTitle(storeErrorLabel, 'rejected')).toBe(storeErrorLabel)
})
- it('never stacks a second Failed prefix', () => {
+ it('removes historical failure prefixes idempotently', () => {
const once = getToolStatusDisplayTitle('Reading table', 'error')
- expect(once).toBe('Failed reading table')
+ expect(once).toBe('Reading table')
expect(getToolStatusDisplayTitle(once, 'error')).toBe(once)
- expect(getToolStatusDisplayTitle('Failed: Something', 'error')).toBe('Failed: Something')
+ expect(getToolStatusDisplayTitle('Failed: Something', 'error')).toBe('Something')
+ })
+
+ it.each([
+ ['Failed: Failed reading notes', 'Reading notes'],
+ ['Failed: Locating reference material', 'Locating reference material'],
+ ['Failed', 'Tool activity'],
+ ['Reading failed runs', 'Reading failed runs'],
+ ['FailedJobs report', 'FailedJobs report'],
+ ['iPhone metadata', 'iPhone metadata'],
+ ['Failed: eBay metadata', 'eBay metadata'],
+ ['failed reading notes', 'Reading notes'],
+ ['Failed failed reading notes', 'Reading notes'],
+ ])('normalizes only leading outcome wording: %s', (title, expected) => {
+ expect(getToolStatusDisplayTitle(title, 'error')).toBe(expected)
+ expect(getToolStatusDisplayTitle(title, 'rejected')).toBe(expected)
+ expect(getToolStatusDisplayTitle(expected, 'error')).toBe(expected)
+ expect(getToolStatusDisplayTitle(expected, 'rejected')).toBe(expected)
})
it('leaves a store-phrased skip label alone when cancelled', () => {
@@ -702,10 +712,8 @@ describe('terminal-title projection is idempotent', () => {
expect(getToolStatusDisplayTitle(stopped, 'cancelled')).toBe(stopped)
})
- it('still projects an ordinary present-tense title', () => {
- expect(getToolStatusDisplayTitle('Searching Sim docs', 'error')).toBe(
- 'Failed searching Sim docs'
- )
+ it('leaves unsuccessful action wording intact and labels cancellation', () => {
+ expect(getToolStatusDisplayTitle('Searching Sim docs', 'error')).toBe('Searching Sim docs')
expect(getToolStatusDisplayTitle('Running workflow', 'cancelled')).toBe(
'Stopped running workflow'
)
@@ -854,10 +862,10 @@ describe('model-authored activity outcomes', () => {
['success', 'Revisando facturas', 'Revisando facturas'],
['success', 'Stopped checking invoices', 'Stopped checking invoices'],
['success', 'Completed: Check invoices', 'Completed: Check invoices'],
- ['error', 'Failed: Fetching invoices', 'Failed: Fetching invoices'],
- ['error', 'Stopped checking invoices', 'Failed checking invoices'],
- ['error', 'Completed checking invoices', 'Failed checking invoices'],
- ['rejected', 'Failed checking invoices', 'Failed checking invoices'],
+ ['error', 'Failed: Fetching invoices', 'Fetching invoices'],
+ ['error', 'Stopped checking invoices', 'Checking invoices'],
+ ['error', 'Completed checking invoices', 'Checking invoices'],
+ ['rejected', 'Failed checking invoices', 'Checking invoices'],
['cancelled', 'Stopped reading notes', 'Stopped reading notes'],
['interrupted', 'Completed: Check invoices', 'Stopped: Check invoices'],
['skipped', 'Failed: Checking invoices', 'Skipped: Checking invoices'],
diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts
index 6e3dbd905d1..8101ab909e2 100644
--- a/apps/sim/lib/copilot/tools/tool-display.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.ts
@@ -1419,16 +1419,7 @@ export function getToolCompletedTitle(title: string): string | undefined {
return past + title.slice(firstWord.length)
}
-/**
- * Titles that already say the work is over.
- *
- * Two layers project a terminal tense: the client tool store phrases its own
- * error and skip labels ("Attempted to read X", "Skipped reading X"), and this
- * module projects again at the render boundary. Re-projecting an
- * already-projected title stacked prefixes — "Failed: Failed: Attempted to read
- * metadata for thread_tracking" — and even a single pass over a store label
- * reads as doubly hedged. Whichever layer spoke first wins.
- */
+/** Recognize terminal wording already supplied by the tool store or persisted history. */
const TERMINAL_TITLE_PREFIXES = new Set(['Failed', 'Attempted', 'Skipped', 'Stopped'])
function firstWordOf(title: string): string {
@@ -1444,7 +1435,7 @@ function statesTerminalOutcome(title: string): boolean {
/** Apply one terminal outcome prefix while preserving already-resolved titles. */
function getToolOutcomeTitle(
title: string,
- outcome: 'Failed' | 'Stopped' | 'Skipped',
+ outcome: 'Stopped' | 'Skipped',
preserveExistingOutcome: boolean
): string {
if (preserveExistingOutcome && statesTerminalOutcome(title)) return title
@@ -1462,10 +1453,26 @@ function getToolOutcomeTitle(
return `${outcome}: ${title}`
}
+/** Error rows describe the action without failure badges or claims of completion. */
+function getNeutralToolActionTitle(title: string): string {
+ let action = title
+ while (action) {
+ const firstWord = firstWordOf(action)
+ const prefix = firstWord.replace(/:$/, '').toLowerCase()
+ if (!['failed', 'stopped', 'skipped', 'completed'].includes(prefix)) break
+ action = action.slice(firstWord.length).trimStart()
+ }
+ if (!action) return 'Tool activity'
+ if (action === title) return title
+ const firstWord = firstWordOf(action)
+ const gerund = firstWord.charAt(0).toUpperCase() + firstWord.slice(1)
+ return COMPLETED_VERB_REWRITES[gerund] ? gerund + action.slice(firstWord.length) : action
+}
+
/**
* Resolve a tool title at the rendering boundary. Successful calls use a known
* past-tense rewrite when available and otherwise preserve the wording.
- * Failed, stopped, and skipped calls retain explicit outcome labels.
+ * Unsuccessful calls keep a neutral action; stopped and skipped calls retain their labels.
*/
export function getToolStatusDisplayTitle(
title: string,
@@ -1482,7 +1489,7 @@ export function getToolStatusDisplayTitle(
return getToolCompletedTitle(title) ?? title
}
if (status === 'error' || status === 'rejected') {
- return getToolOutcomeTitle(title, 'Failed', !description)
+ return getNeutralToolActionTitle(title)
}
if (status === 'cancelled' || status === 'aborted' || status === 'interrupted') {
return getToolOutcomeTitle(title, 'Stopped', !description)
diff --git a/apps/sim/lib/core/outbox/processor.test.ts b/apps/sim/lib/core/outbox/processor.test.ts
index e63b025eca5..d20af17bc58 100644
--- a/apps/sim/lib/core/outbox/processor.test.ts
+++ b/apps/sim/lib/core/outbox/processor.test.ts
@@ -33,6 +33,7 @@ vi.mock('@/lib/knowledge/application/slack-search/outbox', () => ({
vi.mock('@/lib/knowledge/documents/processing-outbox-handler', () => ({
knowledgeDocumentProcessingOutboxHandlers: {},
}))
+vi.mock('@/lib/mothership/inbox/cleanup-outbox', () => ({ inboxCleanupOutboxHandlers: {} }))
vi.mock('@/lib/organizations/resource-cleanup', () => ({
organizationResourceCleanupOutboxHandlers: {},
}))
diff --git a/apps/sim/lib/core/outbox/processor.ts b/apps/sim/lib/core/outbox/processor.ts
index 7c9fbe9fea7..464460579bc 100644
--- a/apps/sim/lib/core/outbox/processor.ts
+++ b/apps/sim/lib/core/outbox/processor.ts
@@ -18,6 +18,7 @@ import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-sea
import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error'
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery'
+import { inboxCleanupOutboxHandlers } from '@/lib/mothership/inbox/cleanup-outbox'
import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup'
import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications'
import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox'
@@ -42,6 +43,7 @@ const handlers = {
...directGrantOutboxHandlers,
...knowledgeDocumentProcessingOutboxHandlers,
...organizationResourceCleanupOutboxHandlers,
+ ...inboxCleanupOutboxHandlers,
...permissionAccessRequestOutboxHandlers,
...workspaceFileLiveDocOutboxHandlers,
...workspaceFileStorageCleanupOutboxHandlers,
diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts
index 967e2a6e487..f3b857699e4 100644
--- a/apps/sim/lib/core/outbox/service.ts
+++ b/apps/sim/lib/core/outbox/service.ts
@@ -83,7 +83,8 @@ export interface DeferredOutboxHandlerResult {
* Defaults to true for an external acknowledgement with a finite retry
* budget. False is reserved for waits on an internal dependency whose own
* outbox row independently reaches completed or dead-letter, and for
- * bounded continuation after durable progress (`continueOutboxHandler`).
+ * bounded continuation after durable progress (`continueOutboxHandler`),
+ * or external polling with a separately persisted, finite poll allowance.
*/
consumeAttempt?: boolean
}
diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts
index c9a7d3b9e95..cd2664c6bb3 100644
--- a/apps/sim/lib/core/security/csp.ts
+++ b/apps/sim/lib/core/security/csp.ts
@@ -83,12 +83,6 @@ const STATIC_SCRIPT_SRC = [
'https://www.googleadservices.com',
'https://googleads.g.doubleclick.net',
'https://analytics.ahrefs.com',
- // HubSpot tracking (landing pages) — loader plus the
- // analytics/form-tracking/banner scripts it injects as