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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -102,6 +103,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
label: 'File folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
workspace: {
label: 'Workspace',
renderIcon: ({ className }) => <Workspaces className={className} />,
},
past_chat: {
label: 'Past chat',
renderIcon: ({ className }) => <Task className={className} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ describe('toCopyableMarkdown', () => {
])
})

it('keeps an organization resource owner so the pasted chip still resolves', () => {
const message = `See <workspace_resource>${JSON.stringify({
workspaceId: 'sales',
type: 'table',
id: 'table-1',
title: 'Accounts',
})}</workspace_resource>.`

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 <workspace_resource>{"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}</workspace_resource>.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -437,11 +439,16 @@ function WorkspaceResourceMenuContent({
}

interface WorkspaceResourceSubmenuProps {
workspace: { id: string; name: string }
workspace: Pick<Workspace, 'id' | 'name' | 'logoUrl'>
/** 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<Workspace, 'id' | 'name'>) => void
}

/**
Expand All @@ -453,14 +460,25 @@ export function WorkspaceResourceSubmenu({
excludeTypes,
selectFolders,
onSelect,
onSelectWorkspace,
}: WorkspaceResourceSubmenuProps) {
const [open, setOpen] = useState(false)
const icon = (
<IdentityTile initial={getWorkspaceInitial(workspace.name)} logoUrl={workspace.logoUrl} />
)
return (
<DropdownMenuSub open={open} onOpenChange={setOpen}>
<DropdownMenuSubTrigger>
{icon}
<DropdownMenuItemLabel label={workspace.name} />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='flex w-[320px] flex-col overflow-hidden'>
{onSelectWorkspace && (
<DropdownMenuItem onClick={() => onSelectWorkspace(workspace)}>
{icon}
<DropdownMenuItemLabel label={workspace.name} />
</DropdownMenuItem>
)}
<WorkspaceResourceMenuContent
workspaceId={workspace.id}
enabled={open}
Expand All @@ -485,7 +503,7 @@ export function AddResourceDropdown({
onClose,
}: AddResourceDropdownProps) {
const [open, setOpen] = useState(false)
const { data: allWorkspaces = [] } = useWorkspacesQuery(open && Boolean(organizationId))
const { data: allWorkspaces = [] } = useOrderedWorkspacesQuery(open && Boolean(organizationId))
const workspaces = allWorkspaces.filter(
(workspace) => workspace.organizationId === organizationId
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isWorkspaceOwnedContext } from '@/lib/mothership/chat/context-ownership'
import {
computeMentionHighlightRanges,
extractContextTokens,
Expand Down Expand Up @@ -27,6 +28,7 @@ const PORTABLE_KIND_TO_ID_FIELD = {
file: 'fileId',
folder: 'folderId',
filefolder: 'fileFolderId',
workspace: 'workspaceId',
knowledge: 'knowledgeId',
past_chat: 'chatId',
workflow: 'workflowId',
Expand All @@ -43,10 +45,35 @@ 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=<owner>`. Links without it parse
* exactly as before.
*/
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, 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 {
Expand All @@ -72,6 +99,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
}
Expand Down Expand Up @@ -106,7 +135,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
)
}

/**
Expand Down Expand Up @@ -210,11 +244,16 @@ 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)
const workspaceId =
ownerAt === -1 ? undefined : decodeOwner(address.slice(ownerAt + OWNER_PARAM.length))
if (workspaceId === null) continue
links.push({
kind,
id,
id: ownerAt === -1 ? address : address.slice(0, ownerAt),
...(workspaceId ? { workspaceId } : {}),
label: parsePortableChipLabel(label),
start: match.index,
end: match.index + full.length,
Expand All @@ -234,6 +273,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 }
Expand All @@ -243,6 +289,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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<HTMLTextAreaElement | null>
pendingCursorRef: React.MutableRefObject<number | null>
Expand All @@ -108,6 +110,7 @@ export const PlusMenuDropdown = React.memo(
organizationId,
warm,
onResourceSelect,
onWorkspaceSelect,
onClose,
textareaRef,
pendingCursorRef,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -413,6 +425,7 @@ export const PlusMenuDropdown = React.memo(
excludeTypes={WORKSPACE_SUBMENU_EXCLUDED_TYPES}
selectFolders
onSelect={handleSelect}
onSelectWorkspace={handleWorkspaceSelect}
/>
))}
<ResourceMenuSections
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export function PromptEditor({
organizationId={editor.organizationId}
warm={hasFocused}
onResourceSelect={editor.insertResource}
onWorkspaceSelect={editor.insertWorkspace}
onClose={editor.handlePlusMenuClose}
textareaRef={editor.textareaRef}
pendingCursorRef={editor.pendingCursorRef}
Expand Down
Loading
Loading