Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/add-connector/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ argument-hint: <service-name> [api-docs-url]

## Choose the connector runtime first

For **Sim Search**, use the live provider workflow in [the federated Search developer guide](../../../apps/sim/lib/sim-search/live/README.md#adding-a-live-search-connector). Its browser-safe provider catalog owns provider IDs, API origins, credential aliases, and account modes; its typed runtime registry requires both search and read handlers. `ConnectorMeta` remains the owner of logos and setup fields. Member mode has no admin resource filters. Service mode requires independent live source verification and shared selectors. Do not implement a Search source by adding a crawler, embeddings, or a scheduled ACL build.
For **Sim Search**, use the live provider workflow in [the federated Search developer guide](../../../apps/sim/lib/sim-search/live/README.md#adding-a-live-search-connector). Its browser-safe provider catalog owns provider IDs, API origins, credential aliases, and account modes; its typed runtime registry requires both search and read handlers. `ConnectorMeta` remains the owner of logos and setup fields. Member mode has no admin resource filters. Service mode requires independent live source verification; resource pickers use shared selectors with canonical manual-input pairs, and plain inputs are fine where no picker applies. Do not implement a Search source by adding a crawler, embeddings, or a scheduled ACL build.

The ingestion instructions below apply to **ordinary knowledge-base connectors** and the explicit legacy Search backend (`SIM_SEARCH_LIVE=false`). If a provider supports both, implement and test both runtimes; adding `search: true` to metadata alone does not implement federated search. Preserve indexing documentation and behavior for those KB/legacy callers.

Expand Down
11 changes: 6 additions & 5 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,


**One member avatar.** Every member list, owner cell, and ranking renders emcn
`<Avatar size='xs' name={…} src={…} aria-hidden />` — a 14px photo, or the initial
on the neutral disc (`aria-hidden` because the name is always beside it). Never
hand-roll an avatar or give a person a `getUserColor` hash; per-person colors
belong to live collaboration (presence, cursors), where the color matches that
person's cursor.
`<Avatar size='xs' name={…} src={…} />` — a 14px photo, or the initial on the
neutral disc. Pass `aria-hidden` only when the member's name is visibly rendered
beside it; an email-only row (`MemberRow`) keeps the avatar labelled because it
carries the name. Never hand-roll an avatar or give a person a `getUserColor`
hash; per-person colors belong to live collaboration (presence, cursors), where
the color matches that person's cursor.

## Header action order

Expand Down
11 changes: 6 additions & 5 deletions .cursor/rules/sim-settings-pages.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,


**One member avatar.** Every member list, owner cell, and ranking renders emcn
`<Avatar size='xs' name={…} src={…} aria-hidden />` — a 14px photo, or the initial
on the neutral disc (`aria-hidden` because the name is always beside it). Never
hand-roll an avatar or give a person a `getUserColor` hash; per-person colors
belong to live collaboration (presence, cursors), where the color matches that
person's cursor.
`<Avatar size='xs' name={…} src={…} />` — a 14px photo, or the initial on the
neutral disc. Pass `aria-hidden` only when the member's name is visibly rendered
beside it; an email-only row (`MemberRow`) keeps the avatar labelled because it
carries the name. Never hand-roll an avatar or give a person a `getUserColor`
hash; per-person colors belong to live collaboration (presence, cursors), where
the color matches that person's cursor.

## Header action order

Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/main/browser-agent/page-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,21 @@ describe('collectSnapshot', () => {
expect(captured.input.ownerDocument).toBe(document)
})

it('climbs out of a shadow root inside a same-origin frame to find the file input', () => {
const frame = document.createElement('iframe')
document.body.append(frame)
const childDocument = frame.contentDocument as Document
childDocument.body.innerHTML = '<div id="host"></div><input type="file">'
const host = childDocument.getElementById('host') as HTMLElement
const button = childDocument.createElement('button')
host.attachShadow({ mode: 'open' }).append(button)
const input = childDocument.querySelector('input') as HTMLInputElement
register(button)

expect(button.getRootNode()).not.toBeInstanceOf(ShadowRoot)
expect(resolveFileInputTarget(0)).toEqual({ input, document: childDocument })
})

it('refuses a disconnected or stale upload reference', () => {
const input = document.createElement('input')
input.type = 'file'
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/browser-agent/page-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3348,7 +3348,7 @@ export function resolveFileInputTarget(id: number): {
)
if (candidates.length === 1) input = candidates[0]
const root = scope.getRootNode()
scope = scope.parentElement ?? (root instanceof ShadowRoot ? root.host : null)
scope = scope.parentElement ?? ('host' in root ? (root.host as Element) : null)
}
if (!input) throw new Error('The selected element has no nearby file input.')
if (input.matches(':disabled')) throw new Error('The file input is disabled.')
Expand Down
23 changes: 22 additions & 1 deletion apps/sim/app/api/desktop/tool/file/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'

const { mockRead, mockSave } = vi.hoisted(() => ({ mockRead: vi.fn(), mockSave: vi.fn() }))
const { mockAdmit, mockRead, mockSave } = vi.hoisted(() => ({
mockAdmit: vi.fn(),
mockRead: vi.fn(),
mockSave: vi.fn(),
}))

vi.mock('@/lib/browser-agent/application/browser-file-transfer', () => ({
admitBrowserDownloadSave: mockAdmit,
readBrowserUploadFile: {
operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' },
execute: mockRead,
Expand Down Expand Up @@ -43,6 +48,7 @@ describe('/api/desktop/tool/file', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue(session)
mockAdmit.mockResolvedValue(undefined)
mockRead.mockResolvedValue({ file: { name: 'Q3 plan.pdf' }, content: Buffer.from('%PDF') })
mockSave.mockResolvedValue({
file: { name: 'report.csv', size: 3, folderPath: null, vfsNamespace: 'files' },
Expand Down Expand Up @@ -92,6 +98,21 @@ describe('/api/desktop/tool/file', () => {
put('toolCallId=c&name=a.csv', new Uint8Array(1), { 'content-length': String(2 ** 31) })
)
expect(oversized.status).toBe(413)
expect(mockAdmit).not.toHaveBeenCalled()
expect(mockSave).not.toHaveBeenCalled()
})

it('refuses an unadmitted save without reading its body', async () => {
mockAdmit.mockRejectedValueOnce(
new OrchestrationError('not_found', 'Browser file transfer not found')
)
const request = put('toolCallId=unknown&name=a.csv', new Uint8Array([1, 2, 3]))

const res = await PUT(request)

expect(res.status).toBe(404)
expect(mockAdmit).toHaveBeenCalledWith(principal, { toolCallId: 'unknown', name: 'a.csv' })
expect(request.bodyUsed).toBe(false)
expect(mockSave).not.toHaveBeenCalled()
})

Expand Down
32 changes: 23 additions & 9 deletions apps/sim/app/api/desktop/tool/file/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@/lib/api/server/routes'
import { withRequestId } from '@/lib/api/server/routes/request-id'
import {
admitBrowserDownloadSave,
readBrowserUploadFile,
saveBrowserDownload,
} from '@/lib/browser-agent/application/browser-file-transfer'
Expand Down Expand Up @@ -52,8 +53,9 @@ export const POST = defineInternalBinaryRoute({
* PUT /api/desktop/tool/file?toolCallId=…&name=…
*
* Raw `withRouteHandler`: the body is the download's bytes, so it is admitted by declared length
* and read under a byte ceiling only after the session is authenticated, then handed to the
* application use case that binds it to its claimed `browser_save_download` call.
* and read under a byte ceiling only after the session is authenticated and the application has
* admitted the caller's claimed `browser_save_download` call, then handed to the use case that
* re-validates and claims that call.
*/
export const PUT = withRouteHandler(async (request: NextRequest) => {
let principal
Expand All @@ -73,6 +75,13 @@ export const PUT = withRouteHandler(async (request: NextRequest) => {
}
const parsed = await parseRequest(saveBrowserDownloadContract, request, {})
if (!parsed.success) return parsed.response
const { toolCallId, name } = parsed.data.query

try {
await admitBrowserDownloadSave(principal, { toolCallId, name })
} catch (error) {
return saveErrorResponse(error)
}

let content: Buffer
try {
Expand All @@ -93,16 +102,21 @@ export const PUT = withRouteHandler(async (request: NextRequest) => {
try {
const { file } = await saveBrowserDownload.execute({
principal,
input: { toolCallId: parsed.data.query.toolCallId, name: parsed.data.query.name, content },
input: { toolCallId, name, content },
request,
})
return NextResponse.json({ path: workspaceFileVfsPath(file), name: file.name, size: file.size })
} catch (error) {
const response = internalFileErrorPolicies.concealContentAuthorization.project(error)
if (!response) throw error
return NextResponse.json(withRequestId(response.body), {
status: response.status,
headers: response.headers,
})
return saveErrorResponse(error)
}
})

/** Projects a typed download-save failure, rethrowing anything the policy does not classify. */
function saveErrorResponse(error: unknown): NextResponse {
const response = internalFileErrorPolicies.concealContentAuthorization.project(error)
if (!response) throw error
return NextResponse.json(withRequestId(response.body), {
status: response.status,
headers: response.headers,
})
}
6 changes: 3 additions & 3 deletions apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle'
import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import OrganizationChatLoading from '@/app/o/[organizationId]/chat/[chatId]/loading'
import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect'
import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'

export const metadata: Metadata = { title: 'Chat' }
Expand All @@ -20,8 +21,8 @@ export default async function OrganizationChatPage({
if (!session?.user?.id) notFound()
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
if (!context) notFound()
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
redirect(WORKSPACE_SETTINGS_PATH)
const homeRedirect = getOrganizationHomeRedirect(context, organizationId)
if (homeRedirect) redirect(homeRedirect)
const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
})
Expand All @@ -38,7 +39,6 @@ export default async function OrganizationChatPage({
</Suspense>
)
}
if (!context.mothershipAvailable) redirect(WORKSPACE_SETTINGS_PATH)
return (
<Suspense fallback={<OrganizationChatLoading />}>
<OrganizationHome
Expand Down
20 changes: 20 additions & 0 deletions apps/sim/app/o/[organizationId]/home/home-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'

/**
* Where organization Home and its chat URLs send a viewer Home cannot serve, or `null` when
* Home renders. Search when Chat is off but member Search is on; workspace settings when the
* viewer can neither both chat and build, nor search as a member.
*/
export function getOrganizationHomeRedirect(
context: Pick<OrganizationSurfaceContext, 'mothershipAvailable' | 'canBuild' | 'searchAccess'>,
organizationId: string
): string | null {
if (!context.mothershipAvailable && context.searchAccess.memberScoped) {
return organizationRoutes(organizationId).search
}
if (!(context.mothershipAvailable && context.canBuild) && !context.searchAccess.memberScoped) {
return WORKSPACE_SETTINGS_PATH
}
return null
}
24 changes: 22 additions & 2 deletions apps/sim/app/o/[organizationId]/home/organization-home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ vi.mock('@/lib/core/utils/browser-storage', () => ({
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
useOrganizationContext: mocks.context,
}))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({ useChat: mocks.chat }))
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({
getMothershipUseChatOptions: (options: object) => ({ ...options, mothership: true }),
useChat: mocks.chat,
}))
vi.mock('@/hooks/queries/mothership-chats', () => ({
useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }),
}))
Expand Down Expand Up @@ -602,7 +605,7 @@ describe('Home permission-selected harness', () => {
expect(mocks.chat).toHaveBeenLastCalledWith(
{ organizationId: 'organization-a' },
'search-a',
expect.objectContaining({ requestMode: 'assistant', projectsDesktopTabs: false })
expect.objectContaining({ requestMode: 'assistant', mothership: true })
)
})
it('keeps old Build history readable after permission removal without a writable composer', async () => {
Expand Down Expand Up @@ -892,6 +895,23 @@ it('restores an explicitly selected search panel on empty Home without reusing t
expect(mocks.send).not.toHaveBeenCalled()
})

it('restores a custom date range with the search panel', async () => {
mocks.activeResource = 'search:organization:organization-a'
await act(async () =>
renderHome(<OrganizationHome />, '?q=Orion&updated=custom&from=2026-09-01&to=2026-09-03')
)
expect(mocks.addResource).toHaveBeenCalledWith(
expect.objectContaining({
search: expect.objectContaining({
filters: {
modifiedAfter: new Date(2026, 8, 1).toISOString(),
modifiedBefore: new Date(2026, 8, 4, 0, 0, 0, -1).toISOString(),
},
}),
})
)
})

it('does not reopen closed search results merely because a query remains in the URL', async () => {
await act(async () => renderHome(<OrganizationHome />, '?searchLevel=adaptive&q=Orion'))
expect(mocks.addResource).not.toHaveBeenCalled()
Expand Down
30 changes: 18 additions & 12 deletions apps/sim/app/o/[organizationId]/home/organization-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat'
import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import {
getMothershipUseChatOptions,
useChat,
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import {
useChatResourcePanel,
useResourcePanelController,
Expand Down Expand Up @@ -84,10 +87,8 @@ function OrganizationHomeContent({
const rememberedMode = useOrganizationChatModeStore(
(state) => state.modes[`${userId}:${organization.id}`]
)
const [{ q, source, updated, searchLevel: urlSearchLevel }, setSearchParams] = useQueryStates(
organizationHomeParsers,
organizationSearchUrlKeys
)
const [{ q, source, updated, from, to, searchLevel: urlSearchLevel }, setSearchParams] =
useQueryStates(organizationHomeParsers, organizationSearchUrlKeys)
const rememberMode = useOrganizationChatModeStore((state) => state.setMode)
const [selectedMode, setSelectedMode] = useState<ChatRequestMode | null>(null)
const planEnabled = useFeatureFlag('mothership-plan-mode')
Expand All @@ -104,12 +105,15 @@ function OrganizationHomeContent({
: 'agent')
const controller = useResourcePanelController()
const queryClient = useQueryClient()
const chat = useChat({ organizationId: organization.id }, chatId, {
requestMode,
projectsDesktopTabs: requestMode !== 'assistant',
onResourceEvent: controller.onResourceEvent,
activeResourceState: controller.activeResourceState,
})
const chat = useChat(
{ organizationId: organization.id },
chatId,
getMothershipUseChatOptions({
requestMode,
onResourceEvent: controller.onResourceEvent,
activeResourceState: controller.activeResourceState,
})
)
const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}`
const draftKey = `${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? 'new'}`
const savedDraft = useMothershipDraftsStore.getState().drafts
Expand Down Expand Up @@ -143,7 +147,7 @@ function OrganizationHomeContent({
const resource = createSearchResource({
scope: { kind: 'organization', organizationId: organization.id },
query: q.trim(),
filters: searchFiltersFromParams({ source, updated }, Date.now()),
filters: searchFiltersFromParams({ source, updated, from, to }, Date.now()),
Comment thread
waleedlatif1 marked this conversation as resolved.
})
if (
controller.activeResourceParam !== resource.id ||
Expand All @@ -156,6 +160,8 @@ function OrganizationHomeContent({
q,
source,
updated,
from,
to,
organization.id,
controller.activeResourceParam,
chat.resources,
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/app/o/[organizationId]/home/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ describe('organization Search page gates', () => {
)
expect(mocks.chat).not.toHaveBeenCalled()
})

it('routes chat URLs exactly where Home routes the same viewer, before loading the chat', async () => {
mocks.context.mockResolvedValue({
mothershipAvailable: true,
canBuild: false,
searchAccess: { memberScoped: false },
})
await expect(OrganizationChatPage({ params })).rejects.toThrow(
'redirect:/workspace?redirect=settings'
)
mocks.context.mockResolvedValue({
mothershipAvailable: false,
canBuild: false,
searchAccess: { memberScoped: true },
})
await expect(OrganizationChatPage({ params })).rejects.toThrow('redirect:/o/org-1/search')
expect(mocks.chat).not.toHaveBeenCalled()
})
})

it('renders standalone Search independently of assistant availability', async () => {
Expand Down
8 changes: 3 additions & 5 deletions apps/sim/app/o/[organizationId]/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect'
import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'

Expand All @@ -21,10 +21,8 @@ export default async function OrganizationHomePage({
if (!session?.user?.id) notFound()
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
if (!context) notFound()
if (!context.mothershipAvailable && context.searchAccess.memberScoped)
redirect(organizationRoutes(organizationId).search)
if (!(context.mothershipAvailable && context.canBuild) && !context.searchAccess.memberScoped)
redirect(WORKSPACE_SETTINGS_PATH)
const homeRedirect = getOrganizationHomeRedirect(context, organizationId)
if (homeRedirect) redirect(homeRedirect)

return (
<Suspense fallback={<HomeFallback />}>
Expand Down
Loading
Loading