diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index f4b53efd06..d73196f34a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -17,6 +17,7 @@ import { parseSpecialTags, SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { useStreamingText } from '@/hooks/use-streaming-text' const LANG_ALIASES: Record = { @@ -119,6 +120,28 @@ const MARKDOWN_COMPONENTS = { ) }, a({ children, href }: { children?: React.ReactNode; href?: string }) { + if (href?.startsWith('#wsres-')) { + return ( + { + e.preventDefault() + const match = href.match(/^#wsres-(\w+)-(.+)$/) + if (match) { + const linkText = e.currentTarget.textContent || match[2] + window.dispatchEvent( + new CustomEvent('wsres-click', { + detail: { type: match[1], id: match[2], title: linkText }, + }) + ) + } + }} + > + {children} + + ) + } return ( void + onWorkspaceResourceSelect?: (resource: MothershipResource) => void smoothStreaming?: boolean } @@ -179,6 +203,7 @@ export function ChatContent({ content, isStreaming = false, onOptionSelect, + onWorkspaceResourceSelect, smoothStreaming = true, }: ChatContentProps) { const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0) @@ -193,6 +218,23 @@ export function ChatContent({ previousIsStreamingRef.current = isStreaming }, [content, isStreaming]) + const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) + onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect + + useEffect(() => { + const handler = (e: Event) => { + const { type, id, title } = (e as CustomEvent).detail + const RESOURCE_TYPE_MAP: Record = {} + onWorkspaceResourceSelectRef.current?.({ + type: RESOURCE_TYPE_MAP[type] || type, + id, + title: title || id, + }) + } + window.addEventListener('wsres-click', handler) + return () => window.removeEventListener('wsres-click', handler) + }, []) + const rendered = useStreamingText(content, isStreaming && smoothStreaming) const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming]) @@ -202,22 +244,37 @@ export function ChatContent({ return (
{parsed.segments.map((segment, i) => { - if (segment.type === 'text' || segment.type === 'thinking') { - return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')} - > - - {segment.content} - -
- ) + if ( + segment.type === 'text' || + segment.type === 'thinking' || + segment.type === 'workspace_resource' + ) { + return null } return ( ) })} + {(() => { + const reassembled = parsed.segments + .map((s) => { + if (s.type === 'workspace_resource') { + const label = s.data.title || s.data.id + return `[${label}](#wsres-${s.data.type}-${s.data.id})` + } + if (s.type === 'text' || s.type === 'thinking') return s.content + return '' + }) + .join('') + if (!reassembled.trim()) return null + return ( +
:first-child]:mt-0 [&>:last-child]:mb-0')}> + + {reassembled} + +
+ ) + })()} {parsed.hasPendingTag && isStreaming && }
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index 473bd7493a..c45b9199c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -9,6 +9,8 @@ export type { RuntimeSpecialTagName, UsageUpgradeAction, UsageUpgradeTagData, + WorkspaceResourceTagData, + WorkspaceResourceTagType, } from './special-tags' export { CREDENTIAL_TAG_TYPES, @@ -20,4 +22,6 @@ export { parseTextTagBody, SpecialTags, USAGE_UPGRADE_ACTIONS, + WORKSPACE_RESOURCE_TAG_TYPES, + WorkspaceResourceDisplay, } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index dc6a032bc6..796dc768cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,10 +1,19 @@ 'use client' -import { createElement, useState } from 'react' +import { createElement, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' +import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import type { + ChatMessageContext, + MothershipResource, +} from '@/app/workspace/[workspaceId]/home/types' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' export interface OptionsItemData { title: string @@ -55,6 +64,16 @@ export interface FileTagData { content: string } +export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const + +export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number] + +export interface WorkspaceResourceTagData { + type: WorkspaceResourceTagType + id: string + title?: string +} + export type ContentSegment = | { type: 'text'; content: string } | { type: 'thinking'; content: string } @@ -62,6 +81,7 @@ export type ContentSegment = | { type: 'usage_upgrade'; data: UsageUpgradeTagData } | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } + | { type: 'workspace_resource'; data: WorkspaceResourceTagData } export type RuntimeSpecialTagName = | 'thinking' @@ -69,6 +89,7 @@ export type RuntimeSpecialTagName = | 'credential' | 'mothership-error' | 'file' + | 'workspace_resource' export interface ParsedSpecialContent { segments: ContentSegment[] @@ -81,6 +102,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'credential', 'mothership-error', 'file', + 'workspace_resource', ] as const const SPECIAL_TAG_NAMES = [ @@ -89,6 +111,7 @@ const SPECIAL_TAG_NAMES = [ 'usage_upgrade', 'credential', 'mothership-error', + 'workspace_resource', ] as const function isRecord(value: unknown): value is Record { @@ -134,6 +157,16 @@ function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagDa ) } +function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceTagData { + if (!isRecord(value)) return false + return ( + typeof value.type === 'string' && + (WORKSPACE_RESOURCE_TAG_TYPES as readonly string[]).includes(value.type) && + typeof value.id === 'string' && + value.id.trim().length > 0 + ) +} + export function parseJsonTagBody( body: string, isExpectedShape: (value: unknown) => value is T @@ -181,6 +214,7 @@ function parseSpecialTagData( | { type: 'usage_upgrade'; data: UsageUpgradeTagData } | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } + | { type: 'workspace_resource'; data: WorkspaceResourceTagData } | null { if (tagName === 'thinking') { const content = parseTextTagBody(body) @@ -207,11 +241,16 @@ function parseSpecialTagData( return data ? { type: 'mothership-error', data } : null } + if (tagName === 'workspace_resource') { + const data = parseJsonTagBody(body, isWorkspaceResourceTagData) + return data ? { type: 'workspace_resource', data } : null + } + return null } /** - * Parses inline special tags (``, ``) from streamed + * Parses inline special tags (``, ``, ``) from streamed * text content. Complete tags are extracted into typed segments; incomplete * tags (still streaming) are suppressed from display and flagged via * `hasPendingTag` so the caller can show a loading indicator. @@ -307,12 +346,18 @@ const THINKING_BLOCKS = [ interface SpecialTagsProps { segment: Exclude onOptionSelect?: (id: string) => void + onWorkspaceResourceSelect?: (resource: MothershipResource) => void } /** - * Unified renderer for inline special tags: ``, ``, and ``. + * Unified renderer for inline special tags: ``, ``, ``, + * and ``. */ -export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) { +export function SpecialTags({ + segment, + onOptionSelect, + onWorkspaceResourceSelect, +}: SpecialTagsProps) { switch (segment.type) { case 'thinking': return null @@ -324,6 +369,8 @@ export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) { return case 'mothership-error': return + case 'workspace_resource': + return default: return null } @@ -413,6 +460,102 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) { ) } +function fallbackWorkspaceResourceTitle(type: WorkspaceResourceTagType): string { + switch (type) { + case 'workflow': + return 'Workflow' + case 'table': + return 'Table' + case 'file': + return 'File' + } +} + +function toMothershipResourceType(type: WorkspaceResourceTagType): MothershipResource['type'] { + return type +} + +function toChatMessageContext(data: WorkspaceResourceTagData, label: string): ChatMessageContext { + switch (data.type) { + case 'workflow': + return { kind: 'workflow', label, workflowId: data.id } + case 'table': + return { kind: 'table', label, tableId: data.id } + case 'file': + return { kind: 'file', label, fileId: data.id } + } +} + +export function WorkspaceResourceDisplay({ + data, + onSelect, +}: { + data: WorkspaceResourceTagData + onSelect?: (resource: MothershipResource) => void +}) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = [] } = useWorkflows(workspaceId) + const { data: tables = [] } = useTablesList(workspaceId) + const { data: files = [] } = useWorkspaceFiles(workspaceId) + const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId) + + const resource = useMemo(() => { + const title = + data.type === 'workflow' + ? (workflows.find((workflow) => workflow.id === data.id)?.name ?? + fallbackWorkspaceResourceTitle(data.type)) + : data.type === 'table' + ? (tables.find((table) => table.id === data.id)?.name ?? + fallbackWorkspaceResourceTitle(data.type)) + : data.type === 'file' + ? (files.find((file) => file.id === data.id)?.name ?? + fallbackWorkspaceResourceTitle(data.type)) + : (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ?? + fallbackWorkspaceResourceTitle(data.type)) + + return { + type: toMothershipResourceType(data.type), + id: data.id, + title, + } + }, [data.id, data.type, files, knowledgeBases, tables, workflows]) + + const context = useMemo(() => toChatMessageContext(data, resource.title), [data, resource.title]) + + const workflowColor = useMemo(() => { + if (data.type !== 'workflow') return null + return workflows.find((workflow) => workflow.id === data.id)?.color ?? null + }, [data.id, data.type, workflows]) + + const mentionContent = ( + <> + + {resource.title} + + ) + + const classes = + 'inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] align-baseline font-[inherit] text-[inherit] leading-[inherit]' + + if (!onSelect) { + return {mentionContent} + } + + return ( + + ) +} + function getCredentialIcon(provider: string): React.ComponentType<{ className?: string }> | null { const lower = provider.toLowerCase() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 4fac008731..a5863d51ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -8,7 +8,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' -import type { ContentBlock, OptionItem, ToolCallData } from '../../types' +import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types' import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' import type { AgentGroupItem } from './components' import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components' @@ -331,6 +331,7 @@ interface MessageContentProps { fallbackContent: string isStreaming: boolean onOptionSelect?: (id: string) => void + onWorkspaceResourceSelect?: (resource: MothershipResource) => void } export function MessageContent({ @@ -338,6 +339,7 @@ export function MessageContent({ fallbackContent, isStreaming = false, onOptionSelect, + onWorkspaceResourceSelect, }: MessageContentProps) { const parsed = blocks.length > 0 ? parseBlocks(blocks) : [] @@ -391,6 +393,7 @@ export function MessageContent({ content={segment.content} isStreaming={isStreaming} onOptionSelect={onOptionSelect} + onWorkspaceResourceSelect={onWorkspaceResourceSelect} smoothStreaming={!hasStructuredSegments} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 527b35ebb3..778111853d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -15,6 +15,7 @@ import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/component import type { ChatMessage, FileAttachmentForApi, + MothershipResource, QueuedMessage, } from '@/app/workspace/[workspaceId]/home/types' import { useAutoScroll } from '@/hooks/use-auto-scroll' @@ -38,6 +39,7 @@ interface MothershipChatProps { chatId?: string onContextAdd?: (context: ChatContext) => void onContextRemove?: (context: ChatContext) => void + onWorkspaceResourceSelect?: (resource: MothershipResource) => void editValue?: string onEditValueConsumed?: () => void layout?: 'mothership-view' | 'copilot-view' @@ -85,6 +87,7 @@ export function MothershipChat({ chatId, onContextAdd, onContextRemove, + onWorkspaceResourceSelect, editValue, onEditValueConsumed, layout = 'mothership-view', @@ -175,6 +178,7 @@ export function MothershipChat({ fallbackContent={msg.content} isStreaming={isThisStreaming} onOptionSelect={isLastMessage ? onSubmit : undefined} + onWorkspaceResourceSelect={onWorkspaceResourceSelect} /> {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 17094e446a..520dc2dee9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -17,7 +17,7 @@ import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks' import type { ChatContext } from '@/stores/panel' import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components' import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks' -import type { FileAttachmentForApi, MothershipResourceType } from './types' +import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types' const logger = createLogger('Home') @@ -299,6 +299,17 @@ export function Home({ chatId }: HomeProps = {}) { [resolveResourceFromContext, removeResource] ) + const handleWorkspaceResourceSelect = useCallback( + (resource: MothershipResource) => { + const wasAdded = addResource(resource) + if (!wasAdded) { + setActiveResourceId(resource.id) + } + handleResourceEvent() + }, + [addResource, handleResourceEvent, setActiveResourceId] + ) + const hasMessages = messages.length > 0 useEffect(() => { @@ -368,6 +379,7 @@ export function Home({ chatId }: HomeProps = {}) { chatId={resolvedChatId} onContextAdd={handleContextAdd} onContextRemove={handleContextRemove} + onWorkspaceResourceSelect={handleWorkspaceResourceSelect} editValue={editingInputValue} onEditValueConsumed={clearEditingValue} animateInput={isInputEntering} diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 86d77f808c..cb91d2051f 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -54,8 +54,9 @@ export interface WorkspaceMdData { connectorTypes?: string[] }> tables: Array<{ id: string; name: string; description?: string | null; rowCount: number }> - files: Array<{ name: string; type: string; size: number }> - credentials: Array<{ providerId: string }> + files: Array<{ id: string; name: string; type: string; size: number }> + oauthIntegrations: Array<{ providerId: string }> + envVariables: string[] tasks?: Array<{ id: string; title: string; updatedAt: Date }> customTools?: Array<{ id: string; name: string }> mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }> @@ -158,21 +159,28 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { } if (data.files.length > 0) { - const lines = data.files.map((f) => `- **${f.name}** (${f.type}, ${formatSize(f.size)})`) + const lines = data.files.map( + (f) => `- **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)}` + ) sections.push(`## Files (${data.files.length})\n${lines.join('\n')}`) } else { sections.push('## Files (0)\n(none)') } - if (data.credentials.length > 0) { - const providers = [...new Set(data.credentials.map((c) => c.providerId))] + if (data.oauthIntegrations.length > 0) { + const providers = [...new Set(data.oauthIntegrations.map((c) => c.providerId))] const lines = providers.map((p) => { const services = PROVIDER_SERVICES[p] return services ? `- ${p} (${services.join(', ')})` : `- ${p}` }) - sections.push(`## Connected Services\n${lines.join('\n')}`) + sections.push(`## Connected Integrations\n${lines.join('\n')}`) } else { - sections.push('## Connected Services\n(none)') + sections.push('## Connected Integrations\n(none)') + } + + if (data.envVariables.length > 0) { + const lines = data.envVariables.map((v) => `- ${v}`) + sections.push(`## Environment Variables (${data.envVariables.length})\n${lines.join('\n')}`) } if (data.customTools && data.customTools.length > 0) { @@ -360,8 +368,9 @@ export async function generateWorkspaceContext( connectorTypes: connectorTypesByKb.get(kb.id), })), tables: tables.map((t, i) => ({ ...t, rowCount: rowCounts[i] ?? 0 })), - files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })), - credentials: credentials.map((c) => ({ providerId: c.providerId })), + files: files.map((f) => ({ id: f.id, name: f.name, type: f.type, size: f.size })), + oauthIntegrations: credentials.map((c) => ({ providerId: c.providerId })), + envVariables: [], customTools: customTools.map((t) => ({ id: t.id, name: t.title })), mcpServers: mcpServerRows, skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })), @@ -382,7 +391,7 @@ export async function generateWorkspaceContext( workspaceId, error: err instanceof Error ? err.message : String(err), }) - return '## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Credentials\n(unavailable)' + return '## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Connected Integrations\n(unavailable)' } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index c22cc23e7c..6888fa9f57 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -380,7 +380,8 @@ export class WorkspaceVFS { knowledgeBases: kbSummary, tables: tblSummary, files: fileSummary, - credentials: envSummary, + oauthIntegrations: envSummary.oauthIntegrations, + envVariables: envSummary.envVariables, tasks: taskSummary, customTools: toolsSummary, mcpServers: mcpServersSummary, @@ -776,7 +777,7 @@ export class WorkspaceVFS { ) } - return files.map((f) => ({ name: f.name, type: f.type, size: f.size })) + return files.map((f) => ({ id: f.id, name: f.name, type: f.type, size: f.size })) } catch (err) { logger.warn('Failed to materialize files', { workspaceId, @@ -1219,7 +1220,10 @@ export class WorkspaceVFS { private async materializeEnvironment( workspaceId: string, userId: string - ): Promise { + ): Promise<{ + oauthIntegrations: WorkspaceMdData['oauthIntegrations'] + envVariables: WorkspaceMdData['envVariables'] + }> { try { const [envCredentials, oauthCredentials, apiKeyRows, envData] = await Promise.all([ getAccessibleEnvCredentials(workspaceId, userId), @@ -1255,16 +1259,18 @@ export class WorkspaceVFS { serializeEnvironmentVariables(personalVarNames, workspaceVarNames) ) - const envKeys = envCredentials.map((c) => c.envKey) - const oauthProviders = oauthCredentials.map((c) => c.providerId) - const allProviders = [...new Set([...oauthProviders, ...envKeys])] - return allProviders.map((key) => ({ providerId: key })) + const oauthProviders = [...new Set(oauthCredentials.map((c) => c.providerId))] + const envKeys = [...new Set(envCredentials.map((c) => c.envKey))] + return { + oauthIntegrations: oauthProviders.map((key) => ({ providerId: key })), + envVariables: envKeys, + } } catch (err) { logger.warn('Failed to materialize environment data', { workspaceId, error: err instanceof Error ? err.message : String(err), }) - return [] + return { oauthIntegrations: [], envVariables: [] } } } }