Skip to content

Commit 6f33c06

Browse files
authored
feat(chat): resolve org-chat mentions in their owner workspace and let chat tag a whole workspace (#8310)
* feat(chat): resolve org-chat mentions in their owner workspace and let chat tag a whole workspace * fix(chat): keep a copied org resource chip's owner workspace and cover each owned kind * fix(chat): paste a chip link with a malformed owner as plain text * test(chat): assert org mention resolution by outcome, not mock calls * test(mothership): centralize the chat workspace-context mock and assert its control route by outcome
1 parent 4e17dce commit 6f33c06

19 files changed

Lines changed: 821 additions & 255 deletions

File tree

‎apps/sim/app/api/mothership/integrations/catalog/route.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
mothershipChatPayloadMock,
44
mothershipChatPayloadMockFns,
55
} from '@sim/testing/mocks/mothership-chat-payload.mock'
6+
import { mothershipChatWorkspaceContextMock } from '@sim/testing/mocks/mothership-chat-workspace-context.mock'
67
import { createMockRequest } from '@sim/testing/mocks/request.mock'
78
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
89
import {
@@ -17,9 +18,10 @@ vi.mock('@/lib/mothership/mcp-tools', () => ({ buildTaggedMcpToolSchemas: vi.fn(
1718
vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock)
1819
vi.mock('@/lib/workspaces/application/workspace-context', () => workspaceContextMock)
1920
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
20-
vi.mock('@/lib/mothership/chat/application/workspace-context', () => ({
21-
readWorkspaceContext: { execute: vi.fn() },
22-
}))
21+
vi.mock(
22+
'@/lib/mothership/chat/application/workspace-context',
23+
() => mothershipChatWorkspaceContextMock
24+
)
2325
vi.mock('@/lib/mothership/request/application/read-control', () => ({
2426
RUN_CONTROL_AUDIENCE: 'control',
2527
readRunControl: { execute: vi.fn() },

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Task,
99
TerminalWindow,
1010
Workflow,
11+
Workspaces,
1112
} from '@sim/emcn/icons'
1213
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
1314
import { getDocumentIcon } from '@/components/icons/document-icons'
@@ -102,6 +103,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
102103
label: 'File folder',
103104
renderIcon: ({ className }) => <FolderIcon className={className} />,
104105
},
106+
workspace: {
107+
label: 'Workspace',
108+
renderIcon: ({ className }) => <Workspaces className={className} />,
109+
},
105110
past_chat: {
106111
label: 'Past chat',
107112
renderIcon: ({ className }) => <Task className={className} />,

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ describe('toCopyableMarkdown', () => {
6666
])
6767
})
6868

69+
it('keeps an organization resource owner so the pasted chip still resolves', () => {
70+
const message = `See <workspace_resource>${JSON.stringify({
71+
workspaceId: 'sales',
72+
type: 'table',
73+
id: 'table-1',
74+
title: 'Accounts',
75+
})}</workspace_resource>.`
76+
77+
const [link] = parseChipLinks(toCopyableMarkdown(message))
78+
79+
expect(link).toMatchObject({ kind: 'table', id: 'table-1', workspaceId: 'sales' })
80+
})
81+
6982
it('copies unresolved file references as plain text', () => {
7083
const message =
7184
'Read <workspace_resource>{"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}</workspace_resource>.'

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function portableWorkspaceResourceMarkdown(
4444
const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles)
4545
return {
4646
markdown: resource
47-
? serializePortableChipLink(data.type, resource.id, resource.title || label)
47+
? serializePortableChipLink(data.type, resource.id, resource.title || label, data.workspaceId)
4848
: label,
4949
hasUnresolvedFile: data.type === 'file' && !resource,
5050
}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import {
1717
Tooltip,
1818
} from '@sim/emcn'
1919
import { Folder, Plus } from '@sim/emcn/icons'
20+
import { IdentityTile } from '@/components/identity-tile/identity-tile'
2021
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
2122
import { isTerminalAvailable } from '@/lib/terminal/transport'
23+
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
2224
import {
2325
type AvailableItemsByType,
2426
type AvailableResources,
@@ -45,7 +47,7 @@ import type {
4547
MothershipResource,
4648
MothershipResourceType,
4749
} from '@/app/workspace/[workspaceId]/home/types'
48-
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
50+
import { useOrderedWorkspacesQuery, type Workspace } from '@/hooks/queries/workspace'
4951

5052
export interface AddResourceDropdownProps {
5153
workspaceId?: string
@@ -437,11 +439,16 @@ function WorkspaceResourceMenuContent({
437439
}
438440

439441
interface WorkspaceResourceSubmenuProps {
440-
workspace: { id: string; name: string }
442+
workspace: Pick<Workspace, 'id' | 'name' | 'logoUrl'>
441443
/** Must be referentially stable (a module constant) — it keys the group memo. */
442444
excludeTypes?: readonly MothershipResourceType[]
443445
selectFolders?: boolean
444446
onSelect: (resource: MothershipResource) => void
447+
/**
448+
* Offers the workspace itself as the first entry, the way a folder submenu
449+
* offers its folder, for pickers that can attach a whole workspace.
450+
*/
451+
onSelectWorkspace?: (workspace: Pick<Workspace, 'id' | 'name'>) => void
445452
}
446453

447454
/**
@@ -453,14 +460,25 @@ export function WorkspaceResourceSubmenu({
453460
excludeTypes,
454461
selectFolders,
455462
onSelect,
463+
onSelectWorkspace,
456464
}: WorkspaceResourceSubmenuProps) {
457465
const [open, setOpen] = useState(false)
466+
const icon = (
467+
<IdentityTile initial={getWorkspaceInitial(workspace.name)} logoUrl={workspace.logoUrl} />
468+
)
458469
return (
459470
<DropdownMenuSub open={open} onOpenChange={setOpen}>
460471
<DropdownMenuSubTrigger>
472+
{icon}
461473
<DropdownMenuItemLabel label={workspace.name} />
462474
</DropdownMenuSubTrigger>
463475
<DropdownMenuSubContent className='flex w-[320px] flex-col overflow-hidden'>
476+
{onSelectWorkspace && (
477+
<DropdownMenuItem onClick={() => onSelectWorkspace(workspace)}>
478+
{icon}
479+
<DropdownMenuItemLabel label={workspace.name} />
480+
</DropdownMenuItem>
481+
)}
464482
<WorkspaceResourceMenuContent
465483
workspaceId={workspace.id}
466484
enabled={open}
@@ -485,7 +503,7 @@ export function AddResourceDropdown({
485503
onClose,
486504
}: AddResourceDropdownProps) {
487505
const [open, setOpen] = useState(false)
488-
const { data: allWorkspaces = [] } = useWorkspacesQuery(open && Boolean(organizationId))
506+
const { data: allWorkspaces = [] } = useOrderedWorkspacesQuery(open && Boolean(organizationId))
489507
const workspaces = allWorkspaces.filter(
490508
(workspace) => workspace.organizationId === organizationId
491509
)

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isWorkspaceOwnedContext } from '@/lib/mothership/chat/context-ownership'
12
import {
23
computeMentionHighlightRanges,
34
extractContextTokens,
@@ -27,6 +28,7 @@ const PORTABLE_KIND_TO_ID_FIELD = {
2728
file: 'fileId',
2829
folder: 'folderId',
2930
filefolder: 'fileFolderId',
31+
workspace: 'workspaceId',
3032
knowledge: 'knowledgeId',
3133
past_chat: 'chatId',
3234
workflow: 'workflowId',
@@ -43,10 +45,35 @@ const PORTABLE_KIND_TO_ID_FIELD = {
4345
*/
4446
export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD
4547

48+
/**
49+
* Carries the owning workspace of a resource chip, which an organization chat
50+
* needs to resolve it: `sim:kind/id?workspace=<owner>`. Links without it parse
51+
* exactly as before.
52+
*/
53+
const OWNER_PARAM = '?workspace='
54+
55+
/**
56+
* Decodes a link's owner, or `null` when it is not valid percent-encoding —
57+
* such a link was not written by this codec, so it stays plain text.
58+
*/
59+
function decodeOwner(encoded: string): string | null {
60+
try {
61+
return decodeURIComponent(encoded)
62+
} catch {
63+
return null
64+
}
65+
}
66+
4667
/** Serializes a portable chip link, escaping Markdown delimiters in its label. */
47-
export function serializePortableChipLink(kind: PortableKind, id: string, label: string): string {
68+
export function serializePortableChipLink(
69+
kind: PortableKind,
70+
id: string,
71+
label: string,
72+
workspaceId?: string
73+
): string {
4874
const escapedLabel = label.replace(/[\\[\]]/g, '\\$&')
49-
return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id})`
75+
const owner = workspaceId ? `${OWNER_PARAM}${encodeURIComponent(workspaceId)}` : ''
76+
return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id}${owner})`
5077
}
5178

5279
function parsePortableChipLabel(label: string): string {
@@ -72,6 +99,8 @@ export interface ParsedChipLink {
7299
kind: PortableKind
73100
id: string
74101
label: string
102+
/** Owning workspace, carried by resource chips copied from an organization chat. */
103+
workspaceId?: string
75104
start: number
76105
end: number
77106
}
@@ -106,7 +135,12 @@ function serializeChipContext(context: ChatContext): string | null {
106135
if (!isPortableKind(context.kind)) return null
107136
const id = getPortableId(context)
108137
if (!id) return null
109-
return serializePortableChipLink(context.kind, id, context.label)
138+
return serializePortableChipLink(
139+
context.kind,
140+
id,
141+
context.label,
142+
isWorkspaceOwnedContext(context) ? context.workspaceId : undefined
143+
)
110144
}
111145

112146
/**
@@ -210,11 +244,16 @@ export function parseChipLinks(text: string): ParsedChipLink[] {
210244
let match: RegExpExecArray | null
211245

212246
while ((match = pattern.exec(text)) !== null) {
213-
const [full, label, kind, id] = match
247+
const [full, label, kind, address] = match
214248
if (!isPortableKind(kind)) continue
249+
const ownerAt = address.lastIndexOf(OWNER_PARAM)
250+
const workspaceId =
251+
ownerAt === -1 ? undefined : decodeOwner(address.slice(ownerAt + OWNER_PARAM.length))
252+
if (workspaceId === null) continue
215253
links.push({
216254
kind,
217-
id,
255+
id: ownerAt === -1 ? address : address.slice(0, ownerAt),
256+
...(workspaceId ? { workspaceId } : {}),
218257
label: parsePortableChipLabel(label),
219258
start: match.index,
220259
end: match.index + full.length,
@@ -234,6 +273,13 @@ export function parseChipLinks(text: string): ParsedChipLink[] {
234273
* @returns The matching chat context.
235274
*/
236275
export function chipLinkToContext(link: ParsedChipLink): ChatContext {
276+
const context = chipLinkBaseContext(link)
277+
return link.workspaceId && isWorkspaceOwnedContext(context)
278+
? { ...context, workspaceId: link.workspaceId }
279+
: context
280+
}
281+
282+
function chipLinkBaseContext(link: ParsedChipLink): ChatContext {
237283
switch (link.kind) {
238284
case 'table':
239285
return { kind: 'table', tableId: link.id, label: link.label }
@@ -243,6 +289,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext {
243289
return { kind: 'folder', folderId: link.id, label: link.label }
244290
case 'filefolder':
245291
return { kind: 'filefolder', fileFolderId: link.id, label: link.label }
292+
case 'workspace':
293+
return { kind: 'workspace', workspaceId: link.id, label: link.label }
246294
case 'knowledge':
247295
return { kind: 'knowledge', knowledgeId: link.id, label: link.label }
248296
case 'past_chat':

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import type {
3939
MothershipResource,
4040
MothershipResourceType,
4141
} from '@/app/workspace/[workspaceId]/home/types'
42-
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
42+
import { useOrderedWorkspacesQuery } from '@/hooks/queries/workspace'
4343
import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands'
4444
import { useBrowserSessionStore } from '@/stores/browser-session/store'
4545
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
@@ -94,6 +94,8 @@ interface PlusMenuDropdownProps {
9494
*/
9595
warm?: boolean
9696
onResourceSelect: (resource: MothershipResource) => void
97+
/** Tags a whole workspace; offered only in organization chats. */
98+
onWorkspaceSelect: (workspace: { id: string; name: string }) => void
9799
onClose: () => void
98100
textareaRef: React.RefObject<HTMLTextAreaElement | null>
99101
pendingCursorRef: React.MutableRefObject<number | null>
@@ -108,6 +110,7 @@ export const PlusMenuDropdown = React.memo(
108110
organizationId,
109111
warm,
110112
onResourceSelect,
113+
onWorkspaceSelect,
111114
onClose,
112115
textareaRef,
113116
pendingCursorRef,
@@ -138,7 +141,7 @@ export const PlusMenuDropdown = React.memo(
138141
enabled: inventoryEnabled,
139142
includeFolderMentions: true,
140143
})
141-
const { data: allWorkspaces = [], isPending: workspacesPending } = useWorkspacesQuery(
144+
const { data: allWorkspaces = [], isPending: workspacesPending } = useOrderedWorkspacesQuery(
142145
Boolean(organizationId) && inventoryEnabled
143146
)
144147
const workspaces = allWorkspaces.filter(
@@ -238,13 +241,22 @@ export const PlusMenuDropdown = React.memo(
238241
if (isMention) setActiveIndex(0)
239242
}, [isMention, mentionQuery])
240243

241-
const handleSelect = (resource: MothershipResource) => {
242-
onResourceSelect(resource)
244+
const closeAfterSelect = () => {
243245
setOpen(false)
244246
setSearch('')
245247
setActiveIndex(0)
246248
}
247249

250+
const handleSelect = (resource: MothershipResource) => {
251+
onResourceSelect(resource)
252+
closeAfterSelect()
253+
}
254+
255+
const handleWorkspaceSelect = (workspace: { id: string; name: string }) => {
256+
onWorkspaceSelect(workspace)
257+
closeAfterSelect()
258+
}
259+
248260
const handleSelectRef = useRef(handleSelect)
249261
handleSelectRef.current = handleSelect
250262

@@ -413,6 +425,7 @@ export const PlusMenuDropdown = React.memo(
413425
excludeTypes={WORKSPACE_SUBMENU_EXCLUDED_TYPES}
414426
selectFolders
415427
onSelect={handleSelect}
428+
onSelectWorkspace={handleWorkspaceSelect}
416429
/>
417430
))}
418431
<ResourceMenuSections

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export function PromptEditor({
226226
organizationId={editor.organizationId}
227227
warm={hasFocused}
228228
onResourceSelect={editor.insertResource}
229+
onWorkspaceSelect={editor.insertWorkspace}
229230
onClose={editor.handlePlusMenuClose}
230231
textareaRef={editor.textareaRef}
231232
pendingCursorRef={editor.pendingCursorRef}

0 commit comments

Comments
 (0)