From 777824c38d818380f89e327de148ac97b6619059 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 15:47:31 -0700 Subject: [PATCH 1/5] feat(chat): resolve org-chat mentions in their owner workspace and let chat tag a whole workspace --- .../chat-context-kind-registry.tsx | 5 + .../add-resource-dropdown.tsx | 24 +- .../components/chip-clipboard-codec.ts | 3 + .../plus-menu-dropdown/plus-menu-dropdown.tsx | 21 +- .../prompt-editor/prompt-editor.tsx | 1 + .../prompt-editor/use-prompt-editor.ts | 42 +- .../[workspaceId]/home/hooks/send-handoff.ts | 2 + .../copilot/components/user-input/utils.ts | 2 + .../lib/mothership/chat/context-ownership.ts | 30 ++ apps/sim/lib/mothership/chat/post.ts | 10 +- .../mothership/chat/process-contents.test.ts | 119 +++++ .../lib/mothership/chat/process-contents.ts | 484 ++++++++++-------- apps/sim/stores/panel/types.ts | 46 +- .../mothership-chat-workspace-context.mock.ts | 48 ++ 14 files changed, 603 insertions(+), 234 deletions(-) create mode 100644 apps/sim/lib/mothership/chat/context-ownership.ts create mode 100644 packages/testing/src/mocks/mothership-chat-workspace-context.mock.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index d20098720e7..626eefe6b1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -8,6 +8,7 @@ import { Task, TerminalWindow, Workflow, + Workspaces, } from '@sim/emcn/icons' import { AgentSkillsIcon, McpIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' @@ -102,6 +103,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, + workspace: { + label: 'Workspace', + renderIcon: ({ className }) => , + }, past_chat: { label: 'Past chat', renderIcon: ({ className }) => , diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index ec9af2c3669..66940356103 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -17,8 +17,10 @@ import { Tooltip, } from '@sim/emcn' import { Folder, Plus } from '@sim/emcn/icons' +import { IdentityTile } from '@/components/identity-tile/identity-tile' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import { isTerminalAvailable } from '@/lib/terminal/transport' +import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { type AvailableItemsByType, type AvailableResources, @@ -45,7 +47,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { useWorkspacesQuery } from '@/hooks/queries/workspace' +import { useOrderedWorkspacesQuery, type Workspace } from '@/hooks/queries/workspace' export interface AddResourceDropdownProps { workspaceId?: string @@ -437,11 +439,16 @@ function WorkspaceResourceMenuContent({ } interface WorkspaceResourceSubmenuProps { - workspace: { id: string; name: string } + workspace: Pick /** Must be referentially stable (a module constant) — it keys the group memo. */ excludeTypes?: readonly MothershipResourceType[] selectFolders?: boolean onSelect: (resource: MothershipResource) => void + /** + * Offers the workspace itself as the first entry, the way a folder submenu + * offers its folder, for pickers that can attach a whole workspace. + */ + onSelectWorkspace?: (workspace: Pick) => void } /** @@ -453,14 +460,25 @@ export function WorkspaceResourceSubmenu({ excludeTypes, selectFolders, onSelect, + onSelectWorkspace, }: WorkspaceResourceSubmenuProps) { const [open, setOpen] = useState(false) + const icon = ( + + ) return ( + {icon} + {onSelectWorkspace && ( + onSelectWorkspace(workspace)}> + {icon} + + + )} workspace.organizationId === organizationId ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 2cc097a1be2..bc783639877 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -27,6 +27,7 @@ const PORTABLE_KIND_TO_ID_FIELD = { file: 'fileId', folder: 'folderId', filefolder: 'fileFolderId', + workspace: 'workspaceId', knowledge: 'knowledgeId', past_chat: 'chatId', workflow: 'workflowId', @@ -243,6 +244,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext { return { kind: 'folder', folderId: link.id, label: link.label } case 'filefolder': return { kind: 'filefolder', fileFolderId: link.id, label: link.label } + case 'workspace': + return { kind: 'workspace', workspaceId: link.id, label: link.label } case 'knowledge': return { kind: 'knowledge', knowledgeId: link.id, label: link.label } case 'past_chat': diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 0d59291a4c7..a15c0a10bc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -39,7 +39,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { useWorkspacesQuery } from '@/hooks/queries/workspace' +import { useOrderedWorkspacesQuery } from '@/hooks/queries/workspace' import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands' import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' @@ -94,6 +94,8 @@ interface PlusMenuDropdownProps { */ warm?: boolean onResourceSelect: (resource: MothershipResource) => void + /** Tags a whole workspace; offered only in organization chats. */ + onWorkspaceSelect: (workspace: { id: string; name: string }) => void onClose: () => void textareaRef: React.RefObject pendingCursorRef: React.MutableRefObject @@ -108,6 +110,7 @@ export const PlusMenuDropdown = React.memo( organizationId, warm, onResourceSelect, + onWorkspaceSelect, onClose, textareaRef, pendingCursorRef, @@ -138,7 +141,7 @@ export const PlusMenuDropdown = React.memo( enabled: inventoryEnabled, includeFolderMentions: true, }) - const { data: allWorkspaces = [], isPending: workspacesPending } = useWorkspacesQuery( + const { data: allWorkspaces = [], isPending: workspacesPending } = useOrderedWorkspacesQuery( Boolean(organizationId) && inventoryEnabled ) const workspaces = allWorkspaces.filter( @@ -238,13 +241,22 @@ export const PlusMenuDropdown = React.memo( if (isMention) setActiveIndex(0) }, [isMention, mentionQuery]) - const handleSelect = (resource: MothershipResource) => { - onResourceSelect(resource) + const closeAfterSelect = () => { setOpen(false) setSearch('') setActiveIndex(0) } + const handleSelect = (resource: MothershipResource) => { + onResourceSelect(resource) + closeAfterSelect() + } + + const handleWorkspaceSelect = (workspace: { id: string; name: string }) => { + onWorkspaceSelect(workspace) + closeAfterSelect() + } + const handleSelectRef = useRef(handleSelect) handleSelectRef.current = handleSelect @@ -413,6 +425,7 @@ export const PlusMenuDropdown = React.memo( excludeTypes={WORKSPACE_SUBMENU_EXCLUDED_TYPES} selectFolders onSelect={handleSelect} + onSelectWorkspace={handleWorkspaceSelect} /> ))} { - const mapped = mapResourceToContext(resource) - if (!mapped) return - const ownerWorkspaceId = resource.workspaceId ?? workspaceIdRef.current - const candidate = - organizationId && ownerWorkspaceId ? { ...mapped, workspaceId: ownerWorkspaceId } : mapped + /** + * Inserts a picked context as an `@label` chip, replacing the `@query` being typed + * when there is one and the caret otherwise. Organization chats and folders reuse + * an existing chip for the same target rather than adding a duplicate. + */ + const insertMention = useCallback( + (candidate: ChatContext, selected: ChatContext[]) => { const context = organizationId || candidate.kind === 'folder' || candidate.kind === 'filefolder' ? (selected.find( @@ -502,6 +503,31 @@ export function usePromptEditor({ [textareaRef, addContextNotified, organizationId] ) + const insertResource = useCallback( + (resource: MothershipResource, selected = contextManagementRef.current.selectedContexts) => { + const mapped = mapResourceToContext(resource) + if (!mapped) return + return insertMention( + organizationId && resource.workspaceId && isWorkspaceOwnedContext(mapped) + ? { ...mapped, workspaceId: resource.workspaceId } + : mapped, + selected + ) + }, + [insertMention, organizationId] + ) + + /** Tags a whole workspace in an organization chat: "I'm working in this one". */ + const insertWorkspace = useCallback( + (workspace: { id: string; name: string }) => { + insertMention( + { kind: 'workspace', workspaceId: workspace.id, label: workspace.name }, + contextManagementRef.current.selectedContexts + ) + }, + [insertMention] + ) + /** * Inserts a batch of resources as `@title` chips (drag-drop path), then * resets the insert anchor so the next non-drop insert uses the cursor. @@ -1320,6 +1346,8 @@ export function usePromptEditor({ /** @internal */ insertResource, /** @internal */ + insertWorkspace, + /** @internal */ handleSkillSelect, /** @internal */ handleMcpSelect, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts index fec8dd4e78a..61e2befef13 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts @@ -100,6 +100,8 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.folderId === 'string' case 'filefolder': return typeof value.fileFolderId === 'string' + case 'workspace': + return typeof value.workspaceId === 'string' case 'docs': return true case 'slash_command': diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index ba7adf37342..7c6b3313ac7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -221,6 +221,8 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean return context.kind === 'folder' && c.folderId === context.folderId case 'filefolder': return context.kind === 'filefolder' && c.fileFolderId === context.fileFolderId + case 'workspace': + return true // The owner comparison above is the whole identity. // Selection kinds scope to part of a resource, so equality is the selected // range — not the file/table — or re-selecting a different passage of an // already-referenced file would be swallowed as a duplicate. diff --git a/apps/sim/lib/mothership/chat/context-ownership.ts b/apps/sim/lib/mothership/chat/context-ownership.ts new file mode 100644 index 00000000000..da8fed96655 --- /dev/null +++ b/apps/sim/lib/mothership/chat/context-ownership.ts @@ -0,0 +1,30 @@ +import type { ChatContext } from '@/stores/panel' + +/** Context kinds that carry an owning `workspaceId` (see `WorkspaceOwned`). */ +const WORKSPACE_OWNED_CONTEXT_KINDS = [ + 'past_chat', + 'workflow', + 'workflow_block', + 'logs', + 'knowledge', + 'table', + 'table_selection', + 'file', + 'file_selection', + 'folder', + 'filefolder', + 'skill', +] as const satisfies readonly ChatContext['kind'][] + +export type WorkspaceOwnedContext = Extract< + ChatContext, + { kind: (typeof WORKSPACE_OWNED_CONTEXT_KINDS)[number] } +> + +const WORKSPACE_OWNED_KINDS: ReadonlySet = new Set( + WORKSPACE_OWNED_CONTEXT_KINDS +) + +export function isWorkspaceOwnedContext(context: ChatContext): context is WorkspaceOwnedContext { + return WORKSPACE_OWNED_KINDS.has(context.kind) +} diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 7216025e601..9822e89d962 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -212,6 +212,7 @@ const ChatContextSchema = z 'mcp', 'browser_tab', 'terminal_tab', + 'workspace', ]), label: z.string(), chatId: z.string().optional(), @@ -242,7 +243,14 @@ const ChatContextSchema = z columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(), selection: z.union([BrowserTextSelectionSchema, TerminalTextSelectionSchema]).optional(), }) - .superRefine(({ kind, selection }, refinementContext) => { + .superRefine(({ kind, selection, workspaceId }, refinementContext) => { + if (kind === 'workspace' && !workspaceId) { + refinementContext.addIssue({ + code: 'custom', + message: 'workspaceId is required for a workspace context', + path: ['workspaceId'], + }) + } if (!selection) return const isTerminalSelection = 'startLine' in selection const selectionMatchesKind = diff --git a/apps/sim/lib/mothership/chat/process-contents.test.ts b/apps/sim/lib/mothership/chat/process-contents.test.ts index f57fd2210c4..9bb36f951e3 100644 --- a/apps/sim/lib/mothership/chat/process-contents.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents.test.ts @@ -9,6 +9,10 @@ import { } from '@sim/testing/mocks/knowledge-base-use-cases.mock' import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { mcpUseCasesMock, mcpUseCasesMockFns } from '@sim/testing/mocks/mcp-use-cases.mock' +import { + mothershipChatWorkspaceContextMock, + mothershipChatWorkspaceContextMockFns, +} from '@sim/testing/mocks/mothership-chat-workspace-context.mock' import { mothershipWorkspaceTargetMock, mothershipWorkspaceTargetMockFns, @@ -57,6 +61,7 @@ const readTableUseCase = tableApplicationTablesMockFns.mockReadTableUseCase const isIntegrationDeploymentAvailable = integrationsAvailabilityMockFns.mockIsIntegrationDeploymentAvailableForVisibility const resolveInvocationWorkspace = mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace +const readWorkspaceContext = mothershipChatWorkspaceContextMockFns.mockReadWorkspaceContextExecute const { getSkillUseCase, @@ -95,6 +100,10 @@ const { })) vi.mock('@/lib/mothership/application/workspace-target', () => mothershipWorkspaceTargetMock) +vi.mock( + '@/lib/mothership/chat/application/workspace-context', + () => mothershipChatWorkspaceContextMock +) vi.mock('@/lib/mothership/block-visibility', () => ({ getBlockVisibilityForCopilot })) vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveMock) @@ -1962,3 +1971,113 @@ it('does not treat a forged built-in identifier as a global template', async () expect(result).toEqual([]) expect(getSkillUseCase).not.toHaveBeenCalled() }) + +describe('organization resource mention targets', () => { + beforeEach(() => { + resolveInvocationWorkspace.mockReset() + resolveInvocationWorkspace.mockImplementation(async (_owner, workspaceId) => { + if (workspaceId !== 'workspace-a') throw new Error('denied') + return { workspaceId } + }) + readWorkflowMetadata.mockReset() + readWorkflowMetadata.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'workspace-a', name: 'Lead intake' }, + folderPath: '/', + }) + listWorkflowFolders.mockClear() + listWorkflowFolders.mockResolvedValue({ + folders: [{ id: 'folder-1', name: 'Leads', parentId: null }], + }) + }) + + it('reads each tagged resource in its authorized owner workspace', async () => { + const result = await processContextsServer( + [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Intake', workspaceId: 'workspace-a' }, + { kind: 'folder', folderId: 'folder-1', label: 'Leads', workspaceId: 'workspace-a' }, + ], + 'user', + '', + undefined, + 'chat', + undefined, + 'org' + ) + expect(result.map((context) => context.content.split('\n')[0])).toEqual([ + 'Workspace workspace-a:', + 'Workspace workspace-a:', + ]) + expect(readWorkflowMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-a' }, + }) + ) + expect(listWorkflowFolders).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ workspaceId: 'workspace-a' }) }) + ) + }) + + it('reads nothing for a resource whose owner workspace is not authorized for the chat', async () => { + const result = await processContextsServer( + [{ kind: 'workflow', workflowId: 'workflow-1', label: 'Foreign', workspaceId: 'foreign' }], + 'user', + '', + undefined, + 'chat', + undefined, + 'org' + ) + expect(result).toEqual([]) + expect(readWorkflowMetadata).not.toHaveBeenCalled() + }) +}) + +describe('workspace mentions', () => { + const workspaceMention: ChatContext = { + kind: 'workspace', + workspaceId: 'workspace-a', + label: 'Sales', + } + + beforeEach(() => { + readWorkspaceContext.mockReset() + }) + + it('describes the workspace through the authorized organization discovery', async () => { + readWorkspaceContext.mockResolvedValue({ + success: true, + workspaces: [{ id: 'workspace-a', name: 'Sales', role: 'write' }], + nextCursor: null, + }) + const [context] = await processContextsServer( + [workspaceMention], + 'user', + '', + undefined, + 'chat', + undefined, + 'org' + ) + expect(context?.type).toBe('workspace') + expect(context?.tag).toBe('@Sales') + expect(context?.content).toContain('{"id":"workspace-a","name":"Sales","role":"write"}') + expect(readWorkspaceContext).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: 'workspace-a' } }) + ) + }) + + it('drops a workspace that organization discovery does not return', async () => { + readWorkspaceContext.mockResolvedValue({ success: true, workspaces: [], nextCursor: null }) + expect( + await processContextsServer( + [workspaceMention], + 'user', + '', + undefined, + 'chat', + undefined, + 'org' + ) + ).toEqual([]) + }) +}) diff --git a/apps/sim/lib/mothership/chat/process-contents.ts b/apps/sim/lib/mothership/chat/process-contents.ts index 2c63211401e..56fd8393915 100644 --- a/apps/sim/lib/mothership/chat/process-contents.ts +++ b/apps/sim/lib/mothership/chat/process-contents.ts @@ -21,10 +21,20 @@ import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' import { createCopilotChatKnowledgePrincipal } from '@/lib/mothership/application/execute-knowledge-use-case' import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' -import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createCopilotChatPrincipal, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/mothership/auth/application-delegation' import { createCopilotChatFilePrincipal } from '@/lib/mothership/auth/file-delegation' import { createCopilotChatTablePrincipal } from '@/lib/mothership/auth/table-delegation' import { getBlockVisibilityForCopilot } from '@/lib/mothership/block-visibility' +import { readWorkspaceContext } from '@/lib/mothership/chat/application/workspace-context' +import { WORKSPACE_TARGET_AUDIENCE } from '@/lib/mothership/chat/application/workspace-target' +import { + isWorkspaceOwnedContext, + type WorkspaceOwnedContext, +} from '@/lib/mothership/chat/context-ownership' import { createChatFolderResolver } from '@/lib/mothership/chat/folder-context' import { MAX_TABLE_SELECTION_COLUMNS, @@ -76,6 +86,7 @@ type AgentContextType = | 'mcp' | 'browser_tab' | 'terminal_tab' + | 'workspace' interface AgentContext { type: AgentContextType @@ -129,223 +140,259 @@ export async function processContextsServer( organizationId?: string ): Promise { if (!Array.isArray(contexts) || contexts.length === 0) return [] - const folderResolver = currentWorkspaceId - ? createChatFolderResolver(userId, currentWorkspaceId, chatId) - : undefined - const resolveContext = async (ctx: ChatContext) => { - try { - if (ctx.kind === 'skill' && ctx.skillId) { - // Global code-owned templates do not require an arbitrary workspace. - const builtin = organizationId ? getBuiltinSkillById(ctx.skillId) : undefined - if (builtin) - return { - type: 'skill' as const, - tag: ctx.label ? `@${ctx.label}` : '@', - content: builtin.content, - } - const target = organizationId - ? await resolveInvocationWorkspace({ userId, organizationId, chatId }, ctx.workspaceId) - : { workspaceId: currentWorkspaceId } - if (!target.workspaceId) return null - const skill = await processSkillFromDb( - ctx.skillId, - target.workspaceId, - ctx.label ? `@${ctx.label}` : '@', - userId, - chatId - ) - return skill && organizationId - ? { ...skill, content: `Workspace ${target.workspaceId}:\n${skill.content}` } - : skill - } - if (ctx.kind === 'mcp' && ctx.serverId && currentWorkspaceId) { - /** The authorized request catalog owns discovery; context identifies the selected service. */ - return { - type: 'mcp', - tag: ctx.label ? `/${ctx.label}` : '/', - content: JSON.stringify({ serverId: ctx.serverId, service: `mcp:${ctx.serverId}` }), - } - } - if (ctx.kind === 'past_chat' && ctx.chatId) { - return await processPastChatFromDb( - ctx.chatId, - userId, - ctx.label ? `@${ctx.label}` : '@', - currentWorkspaceId - ) - } - if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) { - return await processWorkflowFromDb( - ctx.workflowId, - userId, - ctx.label ? `@${ctx.label}` : '@', - ctx.kind, - currentWorkspaceId, - chatId - ) - } - if (ctx.kind === 'knowledge' && ctx.knowledgeId) { - return await processKnowledgeFromDb( - ctx.knowledgeId, - userId, - ctx.label ? `@${ctx.label}` : '@', - currentWorkspaceId, - chatId - ) - } - if ( - (ctx.kind === 'integration' && ctx.blockType) || - (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) - ) { - return await processBlockMetadata( - ctx.kind === 'integration' ? ctx.blockType : ctx.blockIds[0], - ctx.label ? `@${ctx.label}` : '@', - userId, - currentWorkspaceId - ) - } - if (ctx.kind === 'logs' && ctx.executionId) { - return await processExecutionLogFromDb( - ctx.executionId, - userId, - ctx.label ? `@${ctx.label}` : '@', - currentWorkspaceId - ) + /** + * An organization chat has no workspace of its own, so each workspace-owned + * context names its owner and that workspace is authorized for this chat once, + * however many contexts share it. A workspace chat reads everything in its own. + */ + const ownerTargets = new Map>() + const resolveOwner = async (ctx: WorkspaceOwnedContext): Promise => { + if (!organizationId) return currentWorkspaceId + const requested = ctx.workspaceId + if (!requested) return undefined + let target = ownerTargets.get(requested) + if (!target) { + target = resolveInvocationWorkspace({ userId, organizationId, chatId }, requested).then( + (resolved) => resolved.workspaceId + ) + ownerTargets.set(requested, target) + } + return target + } + const folderResolvers = new Map>() + const folderResolverFor = (workspaceId: string) => { + let resolver = folderResolvers.get(workspaceId) + if (!resolver) { + resolver = createChatFolderResolver(userId, workspaceId, chatId) + folderResolvers.set(workspaceId, resolver) + } + return resolver + } + + const resolveContextInWorkspace = async ( + ctx: ChatContext, + workspaceId: string | undefined + ): Promise => { + if (ctx.kind === 'skill' && ctx.skillId) { + if (!workspaceId) return null + return await processSkillFromDb( + ctx.skillId, + workspaceId, + ctx.label ? `@${ctx.label}` : '@', + userId, + chatId + ) + } + if (ctx.kind === 'mcp' && ctx.serverId && workspaceId) { + /** The authorized request catalog owns discovery; context identifies the selected service. */ + return { + type: 'mcp', + tag: ctx.label ? `/${ctx.label}` : '/', + content: JSON.stringify({ serverId: ctx.serverId, service: `mcp:${ctx.serverId}` }), } - /** Desktop context carries a reference and optional selection; v1 has no desktop control tools. */ - if (ctx.kind === 'browser_tab' && ctx.tabId) { - const pointer = `The user pointed at an open browser tab: "${ctx.label}" (tabId ${ctx.tabId}). You cannot read or drive browser tabs here: work from the tab's title and any URL or content the user shares rather than assuming what it shows.` - return { - type: 'browser_tab', - tag: ctx.label ? `@${ctx.label}` : '@', - content: ctx.selection - ? `${pointer}\n\n${formatBrowserSelection(ctx.selection)}` - : pointer, - } + } + if (ctx.kind === 'past_chat' && ctx.chatId) { + return await processPastChatFromDb( + ctx.chatId, + userId, + ctx.label ? `@${ctx.label}` : '@', + workspaceId + ) + } + if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) { + return await processWorkflowFromDb( + ctx.workflowId, + userId, + ctx.label ? `@${ctx.label}` : '@', + ctx.kind, + workspaceId, + chatId + ) + } + if (ctx.kind === 'knowledge' && ctx.knowledgeId) { + return await processKnowledgeFromDb( + ctx.knowledgeId, + userId, + ctx.label ? `@${ctx.label}` : '@', + workspaceId, + chatId + ) + } + if ( + (ctx.kind === 'integration' && ctx.blockType) || + (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) + ) { + return await processBlockMetadata( + ctx.kind === 'integration' ? ctx.blockType : ctx.blockIds[0], + ctx.label ? `@${ctx.label}` : '@', + userId, + workspaceId + ) + } + if (ctx.kind === 'logs' && ctx.executionId) { + return await processExecutionLogFromDb( + ctx.executionId, + userId, + ctx.label ? `@${ctx.label}` : '@', + workspaceId + ) + } + /** Desktop context carries a reference and optional selection; v1 has no desktop control tools. */ + if (ctx.kind === 'browser_tab' && ctx.tabId) { + const pointer = `The user pointed at an open browser tab: "${ctx.label}" (tabId ${ctx.tabId}). You cannot read or drive browser tabs here: work from the tab's title and any URL or content the user shares rather than assuming what it shows.` + return { + type: 'browser_tab', + tag: ctx.label ? `@${ctx.label}` : '@', + content: ctx.selection ? `${pointer}\n\n${formatBrowserSelection(ctx.selection)}` : pointer, } - if (ctx.kind === 'terminal_tab' && ctx.terminalId) { - const pointer = `The user pointed at an open terminal: "${ctx.label}" (terminalId ${ctx.terminalId}). You cannot read or drive terminals here: ask the user to paste the relevant output rather than assuming what is in it.` - return { - type: 'terminal_tab', - tag: ctx.label ? `@${ctx.label}` : '@', - content: ctx.selection - ? `${pointer}\n\n${formatTerminalSelection(ctx.selection)}` - : pointer, - } + } + if (ctx.kind === 'terminal_tab' && ctx.terminalId) { + const pointer = `The user pointed at an open terminal: "${ctx.label}" (terminalId ${ctx.terminalId}). You cannot read or drive terminals here: ask the user to paste the relevant output rather than assuming what is in it.` + return { + type: 'terminal_tab', + tag: ctx.label ? `@${ctx.label}` : '@', + content: ctx.selection + ? `${pointer}\n\n${formatTerminalSelection(ctx.selection)}` + : pointer, } - if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { - return await processWorkflowBlockFromDb( - ctx.workflowId, - userId, - ctx.blockId, - ctx.label, - currentWorkspaceId, - chatId - ) + } + if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { + return await processWorkflowBlockFromDb( + ctx.workflowId, + userId, + ctx.blockId, + ctx.label, + workspaceId, + chatId + ) + } + if (ctx.kind === 'table' && ctx.tableId && workspaceId) { + const result = await resolveTableResource( + ctx.tableId, + workspaceId, + userId, + chatId, + ctx.viewId, + ctx.currentView + ) + if (!result) return null + return { + type: 'table', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, } - if (ctx.kind === 'table' && ctx.tableId && currentWorkspaceId) { - const result = await resolveTableResource( - ctx.tableId, - currentWorkspaceId, - userId, - chatId, - ctx.viewId, - ctx.currentView - ) - if (!result) return null - return { - type: 'table', - tag: ctx.label ? `@${ctx.label}` : '@', - content: result.content, - path: result.path, - } + } + if (ctx.kind === 'file' && ctx.fileId && workspaceId) { + const result = await resolveFileResource(ctx.fileId, workspaceId, userId, chatId) + if (!result) return null + return { + type: 'file', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, } - if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) { - const result = await resolveFileResource(ctx.fileId, currentWorkspaceId, userId, chatId) - if (!result) return null - return { - type: 'file', - tag: ctx.label ? `@${ctx.label}` : '@', - content: result.content, - path: result.path, - } + } + if (ctx.kind === 'file_selection' && ctx.fileId && workspaceId) { + return await resolveFileSelectionResource( + ctx.fileId, + workspaceId, + ctx.text ?? '', + ctx.label, + ctx.startLine, + ctx.endLine, + userId, + chatId + ) + } + if ( + ctx.kind === 'table_selection' && + ctx.tableId && + Array.isArray(ctx.rowIds) && + ctx.rowIds.length > 0 && + workspaceId + ) { + return await resolveTableSelectionResource( + ctx.tableId, + workspaceId, + ctx.rowIds, + ctx.columnIds, + ctx.label, + userId, + chatId + ) + } + if ((ctx.kind === 'folder' || ctx.kind === 'filefolder') && workspaceId) { + const folderId = ctx.kind === 'folder' ? ctx.folderId : ctx.fileFolderId + const path = await folderResolverFor(workspaceId).folderPointer( + folderId, + ctx.kind === 'filefolder' + ) + return { + type: ctx.kind, + tag: ctx.label ? `@${ctx.label}` : '@', + content: path + ? folderReferenceContent(path) + : 'The attached folder could not be resolved in this workspace. Do not guess its contents or substitute a similarly named folder.', } - if (ctx.kind === 'file_selection' && ctx.fileId && currentWorkspaceId) { - return await resolveFileSelectionResource( - ctx.fileId, - currentWorkspaceId, - ctx.text ?? '', - ctx.label, - ctx.startLine, - ctx.endLine, - userId, - chatId + } + if (ctx.kind === 'docs') { + try { + const { searchDocsServerTool } = await import( + '@/lib/mothership/tools/server/docs/search-docs' ) - } - if ( - ctx.kind === 'table_selection' && - ctx.tableId && - Array.isArray(ctx.rowIds) && - ctx.rowIds.length > 0 && - currentWorkspaceId - ) { - return await resolveTableSelectionResource( - ctx.tableId, - currentWorkspaceId, - ctx.rowIds, - ctx.columnIds, - ctx.label, - userId, - chatId + const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' + const query = sanitizeMessageForDocs(rawQuery, contexts) || ctx.label || 'Sim documentation' + const res = await searchDocsServerTool.execute( + { query }, + { + userId, + workspaceId: workspaceId, + chatId, + resolvedSecretTraceRegistry, + } ) - } - if ((ctx.kind === 'folder' || ctx.kind === 'filefolder') && folderResolver) { - const folderId = ctx.kind === 'folder' ? ctx.folderId : ctx.fileFolderId - const path = await folderResolver.folderPointer(folderId, ctx.kind === 'filefolder') + const content = JSON.stringify({ + results: res?.results || [], + ...(res?.note ? { note: res.note } : {}), + }) + return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } + } catch (e) { + logger.error('Failed to process docs context', e) return { - type: ctx.kind, + type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', - content: path - ? folderReferenceContent(path) - : 'The attached folder could not be resolved in this workspace. Do not guess its contents or substitute a similarly named folder.', + content: JSON.stringify({ + results: [], + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry `docs search` later.', + }), } } - if (ctx.kind === 'docs') { - try { - const { searchDocsServerTool } = await import( - '@/lib/mothership/tools/server/docs/search-docs' - ) - const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' - const query = - sanitizeMessageForDocs(rawQuery, contexts) || ctx.label || 'Sim documentation' - const res = await searchDocsServerTool.execute( - { query }, - { - userId, - workspaceId: currentWorkspaceId, - chatId, - resolvedSecretTraceRegistry, - } - ) - const content = JSON.stringify({ - results: res?.results || [], - ...(res?.note ? { note: res.note } : {}), - }) - return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } - } catch (e) { - logger.error('Failed to process docs context', e) - return { - type: 'docs', - tag: ctx.label ? `@${ctx.label}` : '@', - content: JSON.stringify({ - results: [], - note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry `docs search` later.', - }), - } - } + } + return null + } + + const resolveContext = async (ctx: ChatContext): Promise => { + try { + // Global code-owned templates do not require an arbitrary workspace. + const builtin = + ctx.kind === 'skill' && organizationId ? getBuiltinSkillById(ctx.skillId) : undefined + if (builtin) + return { type: 'skill', tag: ctx.label ? `@${ctx.label}` : '@', content: builtin.content } + if (ctx.kind === 'workspace') { + return organizationId && chatId + ? await describeWorkspace(ctx.workspaceId, ctx.label, userId, organizationId, chatId) + : null } - return null + + const workspaceId = isWorkspaceOwnedContext(ctx) + ? await resolveOwner(ctx) + : currentWorkspaceId + const resolved = await resolveContextInWorkspace(ctx, workspaceId) + return resolved && organizationId && workspaceId + ? { ...resolved, content: `Workspace ${workspaceId}:\n${resolved.content}` } + : resolved } catch (error) { logger.error('Failed processing context (server)', { ctx, error }) return null @@ -366,6 +413,35 @@ export async function processContextsServer( return filtered } +/** + * Describes a workspace the user tagged in an organization chat through the same + * authorized discovery the agent itself uses, so a tag reveals nothing discovery + * would not: a workspace outside the organization or the user's access resolves + * to nothing. + */ +async function describeWorkspace( + workspaceId: string, + label: string, + userId: string, + organizationId: string, + chatId: string +): Promise { + const { workspaces } = await readWorkspaceContext.execute({ + principal: createTrustedOrganizationCopilotPrincipal( + { userId, organizationId, chatId, delegationId: `context:${chatId}` }, + { audience: WORKSPACE_TARGET_AUDIENCE, ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS } + ), + input: { workspaceId }, + }) + const [workspace] = workspaces + if (!workspace) return null + return { + type: 'workspace', + tag: label ? `@${label}` : '@', + content: `The user is working in this workspace. Use its id as the target of workspace operations unless they name another.\n${JSON.stringify(workspace)}`, + } +} + function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string { if (!rawMessage) return '' if (!Array.isArray(contexts) || contexts.length === 0) { diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index a851d651f23..1c31b95f0da 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -31,22 +31,36 @@ export interface TerminalTextSelection { endLine: number } +/** + * Names the workspace that owns a referenced resource. An organization chat has + * no workspace of its own, so its contexts carry their owner, which the server + * authorizes before reading anything; a workspace chat's contexts omit it. + */ +interface WorkspaceOwned { + workspaceId?: string +} + export type ChatContext = - | { kind: 'past_chat'; chatId: string; label: string } - | { kind: 'workflow'; workflowId: string; label: string } + | ({ kind: 'past_chat'; chatId: string; label: string } & WorkspaceOwned) + | ({ kind: 'workflow'; workflowId: string; label: string } & WorkspaceOwned) | { kind: 'current_workflow'; workflowId: string; label: string } | { kind: 'blocks'; blockIds: string[]; label: string } - | { kind: 'logs'; executionId?: string; label: string } - | { kind: 'workflow_block'; workflowId: string; blockId: string; label: string } - | { kind: 'knowledge'; knowledgeId?: string; label: string } - | { + | ({ kind: 'logs'; executionId?: string; label: string } & WorkspaceOwned) + | ({ + kind: 'workflow_block' + workflowId: string + blockId: string + label: string + } & WorkspaceOwned) + | ({ kind: 'knowledge'; knowledgeId?: string; label: string } & WorkspaceOwned) + | ({ kind: 'table' tableId: string viewId?: string currentView?: MothershipTableViewContext label: string - } - | { + } & WorkspaceOwned) + | ({ kind: 'table_selection' tableId: string label: string @@ -63,9 +77,9 @@ export type ChatContext = * range; absent when whole rows are selected. */ columnIds?: string[] - } - | { kind: 'file'; fileId: string; label: string } - | { + } & WorkspaceOwned) + | ({ kind: 'file'; fileId: string; label: string } & WorkspaceOwned) + | ({ kind: 'file_selection' fileId: string label: string @@ -84,9 +98,11 @@ export type ChatContext = */ startLine?: number endLine?: number - } - | { kind: 'folder'; folderId: string; label: string } - | { kind: 'filefolder'; fileFolderId: string; label: string } + } & WorkspaceOwned) + | ({ kind: 'folder'; folderId: string; label: string } & WorkspaceOwned) + | ({ kind: 'filefolder'; fileFolderId: string; label: string } & WorkspaceOwned) + /** A whole workspace in an organization chat: "I'm working in this one". */ + | { kind: 'workspace'; workspaceId: string; label: string } | { kind: 'docs'; label: string } /** * A tab in the desktop browser or terminal panel, dragged into the input to @@ -98,7 +114,7 @@ export type ChatContext = | { kind: 'terminal_tab'; terminalId: string; label: string; selection?: TerminalTextSelection } | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } - | { kind: 'skill'; skillId: string; label: string; workspaceId?: string } + | ({ kind: 'skill'; skillId: string; label: string } & WorkspaceOwned) | { kind: 'mcp' serverId: string diff --git a/packages/testing/src/mocks/mothership-chat-workspace-context.mock.ts b/packages/testing/src/mocks/mothership-chat-workspace-context.mock.ts new file mode 100644 index 00000000000..cb8ac76963b --- /dev/null +++ b/packages/testing/src/mocks/mothership-chat-workspace-context.mock.ts @@ -0,0 +1,48 @@ +import { vi } from 'vitest' + +/** + * Controllable mock functions for `@/lib/mothership/chat/application/workspace-context`. + * Both are bare: the workspace a discovery call returns is the behavior callers branch on, + * so set it (or a rejection) per test. + * + * @example + * ```ts + * import { mothershipChatWorkspaceContextMockFns } from '@sim/testing/mocks/mothership-chat-workspace-context.mock' + * + * mothershipChatWorkspaceContextMockFns.mockReadWorkspaceContextExecute.mockResolvedValue({ + * success: true, + * workspaces: [{ id: 'ws-1', name: 'Sales', role: 'write' }], + * nextCursor: null, + * }) + * ``` + */ +export const mothershipChatWorkspaceContextMockFns = { + mockReadWorkspaceContextAuthorize: vi.fn(), + mockReadWorkspaceContextExecute: vi.fn(), +} + +const readWorkspaceContextOperation = { + id: 'mothership.chats.workspace_context', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'copilot.use', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} + +/** + * Static mock module for `@/lib/mothership/chat/application/workspace-context`. + * + * @example + * ```ts + * vi.mock('@/lib/mothership/chat/application/workspace-context', () => mothershipChatWorkspaceContextMock) + * ``` + */ +export const mothershipChatWorkspaceContextMock = { + readWorkspaceContextOperation, + readWorkspaceContext: { + operation: readWorkspaceContextOperation, + authorize: mothershipChatWorkspaceContextMockFns.mockReadWorkspaceContextAuthorize, + execute: mothershipChatWorkspaceContextMockFns.mockReadWorkspaceContextExecute, + }, +} From bae598f05580c202fd21ae3117901f782300c095 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 16:18:50 -0700 Subject: [PATCH 2/5] fix(chat): keep a copied org resource chip's owner workspace and cover each owned kind --- .../mothership-chat/copyable-markdown.test.ts | 13 +++++ .../mothership-chat/copyable-markdown.ts | 2 +- .../components/chip-clipboard-codec.ts | 42 ++++++++++++++-- .../prompt-editor/use-prompt-editor.test.tsx | 48 +++++++++++++++++++ .../prompt-editor/use-prompt-editor.ts | 5 +- .../mothership/chat/process-contents.test.ts | 30 ++++++++++++ 6 files changed, 132 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts index 835022b497a..7afe2494903 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts @@ -66,6 +66,19 @@ describe('toCopyableMarkdown', () => { ]) }) + it('keeps an organization resource owner so the pasted chip still resolves', () => { + const message = `See ${JSON.stringify({ + workspaceId: 'sales', + type: 'table', + id: 'table-1', + title: 'Accounts', + })}.` + + const [link] = parseChipLinks(toCopyableMarkdown(message)) + + expect(link).toMatchObject({ kind: 'table', id: 'table-1', workspaceId: 'sales' }) + }) + it('copies unresolved file references as plain text', () => { const message = 'Read {"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}.' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts index 0a27f697f6a..1f4742101b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts @@ -44,7 +44,7 @@ function portableWorkspaceResourceMarkdown( const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles) return { markdown: resource - ? serializePortableChipLink(data.type, resource.id, resource.title || label) + ? serializePortableChipLink(data.type, resource.id, resource.title || label, data.workspaceId) : label, hasUnresolvedFile: data.type === 'file' && !resource, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index bc783639877..85a57df0b4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -1,3 +1,4 @@ +import { isWorkspaceOwnedContext } from '@/lib/mothership/chat/context-ownership' import { computeMentionHighlightRanges, extractContextTokens, @@ -44,10 +45,23 @@ const PORTABLE_KIND_TO_ID_FIELD = { */ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD +/** + * Carries the owning workspace of a resource chip, which an organization chat + * needs to resolve it: `sim:kind/id?workspace=`. Links without it parse + * exactly as before. + */ +const OWNER_PARAM = '?workspace=' + /** Serializes a portable chip link, escaping Markdown delimiters in its label. */ -export function serializePortableChipLink(kind: PortableKind, id: string, label: string): string { +export function serializePortableChipLink( + kind: PortableKind, + id: string, + label: string, + workspaceId?: string +): string { const escapedLabel = label.replace(/[\\[\]]/g, '\\$&') - return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id})` + const owner = workspaceId ? `${OWNER_PARAM}${encodeURIComponent(workspaceId)}` : '' + return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id}${owner})` } function parsePortableChipLabel(label: string): string { @@ -73,6 +87,8 @@ export interface ParsedChipLink { kind: PortableKind id: string label: string + /** Owning workspace, carried by resource chips copied from an organization chat. */ + workspaceId?: string start: number end: number } @@ -107,7 +123,12 @@ function serializeChipContext(context: ChatContext): string | null { if (!isPortableKind(context.kind)) return null const id = getPortableId(context) if (!id) return null - return serializePortableChipLink(context.kind, id, context.label) + return serializePortableChipLink( + context.kind, + id, + context.label, + isWorkspaceOwnedContext(context) ? context.workspaceId : undefined + ) } /** @@ -211,11 +232,15 @@ export function parseChipLinks(text: string): ParsedChipLink[] { let match: RegExpExecArray | null while ((match = pattern.exec(text)) !== null) { - const [full, label, kind, id] = match + const [full, label, kind, address] = match if (!isPortableKind(kind)) continue + const ownerAt = address.lastIndexOf(OWNER_PARAM) links.push({ kind, - id, + id: ownerAt === -1 ? address : address.slice(0, ownerAt), + ...(ownerAt === -1 + ? {} + : { workspaceId: decodeURIComponent(address.slice(ownerAt + OWNER_PARAM.length)) }), label: parsePortableChipLabel(label), start: match.index, end: match.index + full.length, @@ -235,6 +260,13 @@ export function parseChipLinks(text: string): ParsedChipLink[] { * @returns The matching chat context. */ export function chipLinkToContext(link: ParsedChipLink): ChatContext { + const context = chipLinkBaseContext(link) + return link.workspaceId && isWorkspaceOwnedContext(context) + ? { ...context, workspaceId: link.workspaceId } + : context +} + +function chipLinkBaseContext(link: ParsedChipLink): ChatContext { switch (link.kind) { case 'table': return { kind: 'table', tableId: link.id, label: link.label } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 51fada484d5..4c73470ae9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -171,6 +171,54 @@ it('preserves an explicit cross-workspace resource owner in the organization men } }) +it('keeps a copied organization resource chip addressed to its owner workspace on paste', () => { + const { result, textarea, unmount } = renderPromptEditor({ + workspaceId: '', + organizationId: 'org-1', + }) + try { + act(() => + result().insertResource({ + type: 'table', + id: 'table-1', + title: 'Accounts', + workspaceId: 'sales', + }) + ) + textarea.value = result().value + textarea.setSelectionRange(0, textarea.value.length) + let copied = '' + act(() => { + result().handleCopy({ + currentTarget: textarea, + clipboardData: { + setData: (_type: string, value: string) => { + copied = value + }, + }, + preventDefault: () => {}, + } as unknown as React.ClipboardEvent) + }) + + act(() => result().clear()) + textarea.value = '' + textarea.setSelectionRange(0, 0) + act(() => { + result().handlePaste({ + currentTarget: textarea, + clipboardData: { getData: (type: string) => (type === 'text/plain' ? copied : '') }, + preventDefault: () => {}, + } as unknown as React.ClipboardEvent) + }) + + expect(result().contexts).toEqual([ + { kind: 'table', tableId: 'table-1', label: 'Accounts', workspaceId: 'sales' }, + ]) + } finally { + unmount() + } +}) + it('auto-registers unique organization skill names with their owner but leaves ambiguous names unresolved', () => { const skill = { id: 'built-in', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index ba3c7c38b34..a3e58fff4c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -507,9 +507,10 @@ export function usePromptEditor({ (resource: MothershipResource, selected = contextManagementRef.current.selectedContexts) => { const mapped = mapResourceToContext(resource) if (!mapped) return + const ownerWorkspaceId = resource.workspaceId ?? workspaceIdRef.current return insertMention( - organizationId && resource.workspaceId && isWorkspaceOwnedContext(mapped) - ? { ...mapped, workspaceId: resource.workspaceId } + organizationId && ownerWorkspaceId && isWorkspaceOwnedContext(mapped) + ? { ...mapped, workspaceId: ownerWorkspaceId } : mapped, selected ) diff --git a/apps/sim/lib/mothership/chat/process-contents.test.ts b/apps/sim/lib/mothership/chat/process-contents.test.ts index 9bb36f951e3..f36bde7f2c2 100644 --- a/apps/sim/lib/mothership/chat/process-contents.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents.test.ts @@ -2017,6 +2017,36 @@ describe('organization resource mention targets', () => { ) }) + it.each<[string, ChatContext, Mock, Record]>([ + [ + 'table', + { kind: 'table', tableId: 'table-1', label: 'Accounts', workspaceId: 'workspace-a' }, + readTableUseCase, + { tableId: 'table-1', workspaceId: 'workspace-a' }, + ], + [ + 'file', + { kind: 'file', fileId: 'file-1', label: 'Notes', workspaceId: 'workspace-a' }, + readWorkspaceFileMetadata, + { fileId: 'file-1', assertedWorkspaceId: 'workspace-a' }, + ], + [ + 'knowledge base', + { kind: 'knowledge', knowledgeId: 'kb-1', label: 'Docs', workspaceId: 'workspace-a' }, + readKnowledgeBase, + { knowledgeBaseId: 'kb-1', assertedWorkspaceId: 'workspace-a' }, + ], + ])('reads a tagged %s in its authorized owner workspace', async (_kind, context, read, input) => { + read.mockClear() + await processContextsServer([context], 'user', '', undefined, 'chat', undefined, 'org') + expect(read).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ workspaceId: 'workspace-a' }), + input: expect.objectContaining(input), + }) + ) + }) + it('reads nothing for a resource whose owner workspace is not authorized for the chat', async () => { const result = await processContextsServer( [{ kind: 'workflow', workflowId: 'workflow-1', label: 'Foreign', workspaceId: 'foreign' }], From 07644fbc3313d636f7163a530fb5e847bc615412 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 16:27:50 -0700 Subject: [PATCH 3/5] fix(chat): paste a chip link with a malformed owner as plain text --- .../components/chip-clipboard-codec.ts | 19 ++++++++++++--- .../prompt-editor/use-prompt-editor.test.tsx | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 85a57df0b4b..c9292a620d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -52,6 +52,18 @@ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD */ const OWNER_PARAM = '?workspace=' +/** + * Decodes a link's owner, or `null` when it is not valid percent-encoding — + * such a link was not written by this codec, so it stays plain text. + */ +function decodeOwner(encoded: string): string | null { + try { + return decodeURIComponent(encoded) + } catch { + return null + } +} + /** Serializes a portable chip link, escaping Markdown delimiters in its label. */ export function serializePortableChipLink( kind: PortableKind, @@ -235,12 +247,13 @@ export function parseChipLinks(text: string): ParsedChipLink[] { const [full, label, kind, address] = match if (!isPortableKind(kind)) continue const ownerAt = address.lastIndexOf(OWNER_PARAM) + const workspaceId = + ownerAt === -1 ? undefined : decodeOwner(address.slice(ownerAt + OWNER_PARAM.length)) + if (workspaceId === null) continue links.push({ kind, id: ownerAt === -1 ? address : address.slice(0, ownerAt), - ...(ownerAt === -1 - ? {} - : { workspaceId: decodeURIComponent(address.slice(ownerAt + OWNER_PARAM.length)) }), + ...(workspaceId ? { workspaceId } : {}), label: parsePortableChipLabel(label), start: match.index, end: match.index + full.length, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 4c73470ae9d..a4007b498a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -219,6 +219,30 @@ it('keeps a copied organization resource chip addressed to its owner workspace o } }) +it('pastes a chip link with a malformed owner as the plain text it is', () => { + const { result, textarea, unmount } = renderPromptEditor({ + workspaceId: '', + organizationId: 'org-1', + }) + const preventDefault = vi.fn() + try { + act(() => { + result().handlePaste({ + currentTarget: textarea, + clipboardData: { + getData: (type: string) => + type === 'text/plain' ? '[Notes](sim:file/file-1?workspace=100%)' : '', + }, + preventDefault, + } as unknown as React.ClipboardEvent) + }) + expect(preventDefault).not.toHaveBeenCalled() + expect(result().contexts).toEqual([]) + } finally { + unmount() + } +}) + it('auto-registers unique organization skill names with their owner but leaves ambiguous names unresolved', () => { const skill = { id: 'built-in', From 0152867272e141621b5b429f41660959e1f667ce Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 16:37:54 -0700 Subject: [PATCH 4/5] test(chat): assert org mention resolution by outcome, not mock calls --- .../prompt-editor/use-prompt-editor.test.tsx | 8 +- .../mothership/chat/process-contents.test.ts | 131 ++++++++++-------- 2 files changed, 77 insertions(+), 62 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index a4007b498a2..e0abea2236c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -224,7 +224,7 @@ it('pastes a chip link with a malformed owner as the plain text it is', () => { workspaceId: '', organizationId: 'org-1', }) - const preventDefault = vi.fn() + let nativePasteCancelled = false try { act(() => { result().handlePaste({ @@ -233,10 +233,12 @@ it('pastes a chip link with a malformed owner as the plain text it is', () => { getData: (type: string) => type === 'text/plain' ? '[Notes](sim:file/file-1?workspace=100%)' : '', }, - preventDefault, + preventDefault: () => { + nativePasteCancelled = true + }, } as unknown as React.ClipboardEvent) }) - expect(preventDefault).not.toHaveBeenCalled() + expect(nativePasteCancelled).toBe(false) expect(result().contexts).toEqual([]) } finally { unmount() diff --git a/apps/sim/lib/mothership/chat/process-contents.test.ts b/apps/sim/lib/mothership/chat/process-contents.test.ts index f36bde7f2c2..4d2d69b5247 100644 --- a/apps/sim/lib/mothership/chat/process-contents.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents.test.ts @@ -1973,6 +1973,29 @@ it('does not treat a forged built-in identifier as a global template', async () }) describe('organization resource mention targets', () => { + /** + * Resolves only for a principal and input both scoped to the authorized owner + * workspace, as the real use cases enforce, so a read addressed anywhere else + * yields no context. + */ + const ownedBy = + (workspaceId: string, value: unknown) => + async ({ + principal, + input, + }: { + principal: { workspaceId?: string } + input: { workspaceId?: string; assertedWorkspaceId?: string } + }) => { + if ( + principal.workspaceId !== workspaceId || + (input.workspaceId ?? input.assertedWorkspaceId) !== workspaceId + ) { + throw new DelegatedWorkspaceAuthorizationError() + } + return value + } + beforeEach(() => { resolveInvocationWorkspace.mockReset() resolveInvocationWorkspace.mockImplementation(async (_owner, workspaceId) => { @@ -1980,74 +2003,63 @@ describe('organization resource mention targets', () => { return { workspaceId } }) readWorkflowMetadata.mockReset() - readWorkflowMetadata.mockResolvedValue({ - workflow: { id: 'workflow-1', workspaceId: 'workspace-a', name: 'Lead intake' }, - folderPath: '/', - }) - listWorkflowFolders.mockClear() - listWorkflowFolders.mockResolvedValue({ - folders: [{ id: 'folder-1', name: 'Leads', parentId: null }], - }) - }) - - it('reads each tagged resource in its authorized owner workspace', async () => { - const result = await processContextsServer( - [ - { kind: 'workflow', workflowId: 'workflow-1', label: 'Intake', workspaceId: 'workspace-a' }, - { kind: 'folder', folderId: 'folder-1', label: 'Leads', workspaceId: 'workspace-a' }, - ], - 'user', - '', - undefined, - 'chat', - undefined, - 'org' + readWorkflowMetadata.mockImplementation( + ownedBy('workspace-a', { + workflow: { id: 'workflow-1', workspaceId: 'workspace-a', name: 'Lead intake' }, + folderPath: '/', + }) ) - expect(result.map((context) => context.content.split('\n')[0])).toEqual([ - 'Workspace workspace-a:', - 'Workspace workspace-a:', - ]) - expect(readWorkflowMetadata).toHaveBeenCalledWith( - expect.objectContaining({ - input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-a' }, + listWorkflowFolders.mockImplementation( + ownedBy('workspace-a', { folders: [{ id: 'folder-1', name: 'Leads', parentId: null }] }) + ) + readTableUseCase.mockImplementation( + ownedBy('workspace-a', { + table: { id: 'table-1', name: 'Accounts', workspaceId: 'workspace-a', schema: {} }, + folderPath: '/', }) ) - expect(listWorkflowFolders).toHaveBeenCalledWith( - expect.objectContaining({ input: expect.objectContaining({ workspaceId: 'workspace-a' }) }) + readWorkspaceFileMetadata.mockImplementation( + ownedBy('workspace-a', { file: { name: 'Notes.md', folderPath: null } }) + ) + readKnowledgeBase.mockImplementation( + ownedBy('workspace-a', { knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }) ) }) - it.each<[string, ChatContext, Mock, Record]>([ + it.each<[string, ChatContext]>([ [ - 'table', - { kind: 'table', tableId: 'table-1', label: 'Accounts', workspaceId: 'workspace-a' }, - readTableUseCase, - { tableId: 'table-1', workspaceId: 'workspace-a' }, + 'workflow', + { kind: 'workflow', workflowId: 'workflow-1', label: 'Intake', workspaceId: 'workspace-a' }, ], [ - 'file', - { kind: 'file', fileId: 'file-1', label: 'Notes', workspaceId: 'workspace-a' }, - readWorkspaceFileMetadata, - { fileId: 'file-1', assertedWorkspaceId: 'workspace-a' }, + 'folder', + { kind: 'folder', folderId: 'folder-1', label: 'Leads', workspaceId: 'workspace-a' }, ], + ['table', { kind: 'table', tableId: 'table-1', label: 'Accounts', workspaceId: 'workspace-a' }], + ['file', { kind: 'file', fileId: 'file-1', label: 'Notes', workspaceId: 'workspace-a' }], [ 'knowledge base', { kind: 'knowledge', knowledgeId: 'kb-1', label: 'Docs', workspaceId: 'workspace-a' }, - readKnowledgeBase, - { knowledgeBaseId: 'kb-1', assertedWorkspaceId: 'workspace-a' }, ], - ])('reads a tagged %s in its authorized owner workspace', async (_kind, context, read, input) => { - read.mockClear() - await processContextsServer([context], 'user', '', undefined, 'chat', undefined, 'org') - expect(read).toHaveBeenCalledWith( - expect.objectContaining({ - principal: expect.objectContaining({ workspaceId: 'workspace-a' }), - input: expect.objectContaining(input), - }) + ])('reads a tagged %s in its authorized owner workspace', async (_kind, context) => { + const result = await processContextsServer( + [context], + 'user', + '', + undefined, + 'chat', + undefined, + 'org' ) + expect(result).toHaveLength(1) + expect(result[0]?.content.startsWith('Workspace workspace-a:\n')).toBe(true) }) it('reads nothing for a resource whose owner workspace is not authorized for the chat', async () => { + readWorkflowMetadata.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'foreign', name: 'Elsewhere' }, + folderPath: '/', + }) const result = await processContextsServer( [{ kind: 'workflow', workflowId: 'workflow-1', label: 'Foreign', workspaceId: 'foreign' }], 'user', @@ -2058,7 +2070,6 @@ describe('organization resource mention targets', () => { 'org' ) expect(result).toEqual([]) - expect(readWorkflowMetadata).not.toHaveBeenCalled() }) }) @@ -2074,11 +2085,16 @@ describe('workspace mentions', () => { }) it('describes the workspace through the authorized organization discovery', async () => { - readWorkspaceContext.mockResolvedValue({ - success: true, - workspaces: [{ id: 'workspace-a', name: 'Sales', role: 'write' }], - nextCursor: null, - }) + readWorkspaceContext.mockImplementation( + async ({ input }: { input: { workspaceId: string } }) => ({ + success: true, + workspaces: + input.workspaceId === 'workspace-a' + ? [{ id: 'workspace-a', name: 'Sales', role: 'write' }] + : [], + nextCursor: null, + }) + ) const [context] = await processContextsServer( [workspaceMention], 'user', @@ -2091,9 +2107,6 @@ describe('workspace mentions', () => { expect(context?.type).toBe('workspace') expect(context?.tag).toBe('@Sales') expect(context?.content).toContain('{"id":"workspace-a","name":"Sales","role":"write"}') - expect(readWorkspaceContext).toHaveBeenCalledWith( - expect.objectContaining({ input: { workspaceId: 'workspace-a' } }) - ) }) it('drops a workspace that organization discovery does not return', async () => { From fa5a114b6bb5147cbe9760eb2448e784e228a8bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 16:50:28 -0700 Subject: [PATCH 5/5] test(mothership): centralize the chat workspace-context mock and assert its control route by outcome --- .../integrations/catalog/route.test.ts | 8 ++-- .../lib/mothership/transport/control.test.ts | 43 +++++++++++++------ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/mothership/integrations/catalog/route.test.ts b/apps/sim/app/api/mothership/integrations/catalog/route.test.ts index 431e3df8f86..a41e08247bf 100644 --- a/apps/sim/app/api/mothership/integrations/catalog/route.test.ts +++ b/apps/sim/app/api/mothership/integrations/catalog/route.test.ts @@ -3,6 +3,7 @@ import { mothershipChatPayloadMock, mothershipChatPayloadMockFns, } from '@sim/testing/mocks/mothership-chat-payload.mock' +import { mothershipChatWorkspaceContextMock } from '@sim/testing/mocks/mothership-chat-workspace-context.mock' import { createMockRequest } from '@sim/testing/mocks/request.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' import { @@ -17,9 +18,10 @@ vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: vi.fn( vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock) vi.mock('@/lib/workspaces/application/workspace-context', () => workspaceContextMock) vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) -vi.mock('@/lib/mothership/chat/application/workspace-context', () => ({ - readWorkspaceContext: { execute: vi.fn() }, -})) +vi.mock( + '@/lib/mothership/chat/application/workspace-context', + () => mothershipChatWorkspaceContextMock +) vi.mock('@/lib/mothership/request/application/read-control', () => ({ RUN_CONTROL_AUDIENCE: 'control', readRunControl: { execute: vi.fn() }, diff --git a/apps/sim/lib/mothership/transport/control.test.ts b/apps/sim/lib/mothership/transport/control.test.ts index 6c5a3cdbe4d..4711ee57915 100644 --- a/apps/sim/lib/mothership/transport/control.test.ts +++ b/apps/sim/lib/mothership/transport/control.test.ts @@ -1,3 +1,7 @@ +import { + mothershipChatWorkspaceContextMock, + mothershipChatWorkspaceContextMockFns, +} from '@sim/testing/mocks/mothership-chat-workspace-context.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' const handlers = vi.hoisted(() => ({ @@ -5,16 +9,16 @@ const handlers = vi.hoisted(() => ({ status: vi.fn(), prepare: vi.fn(), wake: vi.fn(), - workspace: vi.fn(), catalog: vi.fn(), })) vi.mock('@/lib/mothership/integrations/application/catalog', () => ({ INTEGRATION_CATALOG_AUDIENCE: 'catalog', readIntegrationCatalog: { execute: handlers.catalog }, })) -vi.mock('@/lib/mothership/chat/application/workspace-context', () => ({ - readWorkspaceContext: { execute: handlers.workspace }, -})) +vi.mock( + '@/lib/mothership/chat/application/workspace-context', + () => mothershipChatWorkspaceContextMock +) vi.mock('@/lib/mothership/request/application/read-control', () => ({ RUN_CONTROL_AUDIENCE: 'control', readRunControl: { execute: handlers.read }, @@ -35,6 +39,7 @@ import type { } from '@/lib/mothership/generated/sim-transport' import { executeSimControl } from '@/lib/mothership/transport/control' +const readWorkspaceContext = mothershipChatWorkspaceContextMockFns.mockReadWorkspaceContextExecute const scope = { userId: 'user', workspaceId: 'workspace', chatId: 'chat' } function request(operation: SimControlOperation): SimControlRequest { return { id: 'request', scope, operation, expiresAt: Date.now() + 5000 } @@ -174,19 +179,33 @@ describe('outbound control delivery uses the existing authorized operations', () }) it('uses the same protected inventory for checkpoint memory preflight', async () => { - handlers.workspace.mockResolvedValue({ success: true, workspaces: [], nextCursor: null }) + readWorkspaceContext.mockImplementation( + async ({ + principal, + input, + }: { + principal: { kind: string; audience?: string; resourceScope?: { chatId?: string } } + input: { workspaceId: string } + }) => { + if ( + principal.kind !== 'organization_delegated' || + principal.audience !== 'sim:workspaces' || + principal.resourceScope?.chatId !== 'chat' + ) { + throw new OrchestrationError('forbidden', 'Wrong delegated authority') + } + return { success: true, workspaces: [{ id: input.workspaceId }], nextCursor: null } + } + ) const result = await executeSimControl({ ...request({ kind: 'workspace_context', input: { workspaceId: 'target' } }), scope: { organizationId: 'org', userId: 'user', chatId: 'chat' }, }) expect(result.status).toBe(200) - expect(handlers.workspace).toHaveBeenCalledWith({ - input: { workspaceId: 'target' }, - principal: expect.objectContaining({ - kind: 'organization_delegated', - audience: 'sim:workspaces', - resourceScope: { chatId: 'chat' }, - }), + expect(JSON.parse(result.body)).toEqual({ + success: true, + workspaces: [{ id: 'target' }], + nextCursor: null, }) })