Clickable resources

This commit is contained in:
Siddharth Ganesan
2026-04-09 19:34:41 -07:00
parent 33d1342452
commit c026ce715a
8 changed files with 273 additions and 35 deletions

View File

@@ -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<string, string> = {
@@ -119,6 +120,28 @@ const MARKDOWN_COMPONENTS = {
)
},
a({ children, href }: { children?: React.ReactNode; href?: string }) {
if (href?.startsWith('#wsres-')) {
return (
<a
href={href}
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
onClick={(e) => {
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}
</a>
)
}
return (
<a
href={href}
@@ -172,6 +195,7 @@ interface ChatContentProps {
content: string
isStreaming?: boolean
onOptionSelect?: (id: string) => 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<string, string> = {}
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 (
<div className='space-y-3'>
{parsed.segments.map((segment, i) => {
if (segment.type === 'text' || segment.type === 'thinking') {
return (
<div
key={`${segment.type}-${i}`}
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
<Streamdown mode='static' components={MARKDOWN_COMPONENTS}>
{segment.content}
</Streamdown>
</div>
)
if (
segment.type === 'text' ||
segment.type === 'thinking' ||
segment.type === 'workspace_resource'
) {
return null
}
return (
<SpecialTags key={`special-${i}`} segment={segment} onOptionSelect={onOptionSelect} />
)
})}
{(() => {
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 (
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
<Streamdown mode='static' components={MARKDOWN_COMPONENTS}>
{reassembled}
</Streamdown>
</div>
)
})()}
{parsed.hasPendingTag && isStreaming && <PendingTagIndicator />}
</div>
)

View File

@@ -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'

View File

@@ -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<string, unknown> {
@@ -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<T>(
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 (`<options>`, `<usage_upgrade>`) from streamed
* Parses inline special tags (`<options>`, `<usage_upgrade>`, `<workspace_resource>`) 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<ContentSegment, { type: 'text' }>
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}
/**
* Unified renderer for inline special tags: `<options>`, `<usage_upgrade>`, and `<credential>`.
* Unified renderer for inline special tags: `<options>`, `<usage_upgrade>`, `<credential>`,
* and `<workspace_resource>`.
*/
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 <CredentialDisplay data={segment.data} />
case 'mothership-error':
return <MothershipErrorDisplay data={segment.data} />
case 'workspace_resource':
return <WorkspaceResourceDisplay data={segment.data} onSelect={onWorkspaceResourceSelect} />
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<MothershipResource>(() => {
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 = (
<>
<ContextMentionIcon
context={context}
workflowColor={workflowColor}
className='relative top-0.5 h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
/>
{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 <span className={classes}>{mentionContent}</span>
}
return (
<button
type='button'
onClick={() => onSelect(resource)}
className={cn(classes, 'cursor-pointer transition-colors hover-hover:bg-[var(--surface-6)]')}
>
{mentionContent}
</button>
)
}
function getCredentialIcon(provider: string): React.ComponentType<{ className?: string }> | null {
const lower = provider.toLowerCase()

View File

@@ -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}
/>
)

View File

@@ -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) && (
<div className='mt-2.5'>

View File

@@ -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}

View File

@@ -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)'
}
}

View File

@@ -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<WorkspaceMdData['credentials']> {
): 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: [] }
}
}
}