Skip to content

Commit c026ce7

Browse files
committed
Clickable resources
1 parent 33d1342 commit c026ce7

File tree

8 files changed

+273
-35
lines changed

8 files changed

+273
-35
lines changed

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
parseSpecialTags,
1818
SpecialTags,
1919
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
20+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
2021
import { useStreamingText } from '@/hooks/use-streaming-text'
2122

2223
const LANG_ALIASES: Record<string, string> = {
@@ -119,6 +120,28 @@ const MARKDOWN_COMPONENTS = {
119120
)
120121
},
121122
a({ children, href }: { children?: React.ReactNode; href?: string }) {
123+
if (href?.startsWith('#wsres-')) {
124+
return (
125+
<a
126+
href={href}
127+
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
128+
onClick={(e) => {
129+
e.preventDefault()
130+
const match = href.match(/^#wsres-(\w+)-(.+)$/)
131+
if (match) {
132+
const linkText = e.currentTarget.textContent || match[2]
133+
window.dispatchEvent(
134+
new CustomEvent('wsres-click', {
135+
detail: { type: match[1], id: match[2], title: linkText },
136+
})
137+
)
138+
}
139+
}}
140+
>
141+
{children}
142+
</a>
143+
)
144+
}
122145
return (
123146
<a
124147
href={href}
@@ -172,13 +195,15 @@ interface ChatContentProps {
172195
content: string
173196
isStreaming?: boolean
174197
onOptionSelect?: (id: string) => void
198+
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
175199
smoothStreaming?: boolean
176200
}
177201

178202
export function ChatContent({
179203
content,
180204
isStreaming = false,
181205
onOptionSelect,
206+
onWorkspaceResourceSelect,
182207
smoothStreaming = true,
183208
}: ChatContentProps) {
184209
const hydratedStreamingRef = useRef(isStreaming && content.trim().length > 0)
@@ -193,6 +218,23 @@ export function ChatContent({
193218
previousIsStreamingRef.current = isStreaming
194219
}, [content, isStreaming])
195220

221+
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
222+
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
223+
224+
useEffect(() => {
225+
const handler = (e: Event) => {
226+
const { type, id, title } = (e as CustomEvent).detail
227+
const RESOURCE_TYPE_MAP: Record<string, string> = {}
228+
onWorkspaceResourceSelectRef.current?.({
229+
type: RESOURCE_TYPE_MAP[type] || type,
230+
id,
231+
title: title || id,
232+
})
233+
}
234+
window.addEventListener('wsres-click', handler)
235+
return () => window.removeEventListener('wsres-click', handler)
236+
}, [])
237+
196238
const rendered = useStreamingText(content, isStreaming && smoothStreaming)
197239

198240
const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming])
@@ -202,22 +244,37 @@ export function ChatContent({
202244
return (
203245
<div className='space-y-3'>
204246
{parsed.segments.map((segment, i) => {
205-
if (segment.type === 'text' || segment.type === 'thinking') {
206-
return (
207-
<div
208-
key={`${segment.type}-${i}`}
209-
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
210-
>
211-
<Streamdown mode='static' components={MARKDOWN_COMPONENTS}>
212-
{segment.content}
213-
</Streamdown>
214-
</div>
215-
)
247+
if (
248+
segment.type === 'text' ||
249+
segment.type === 'thinking' ||
250+
segment.type === 'workspace_resource'
251+
) {
252+
return null
216253
}
217254
return (
218255
<SpecialTags key={`special-${i}`} segment={segment} onOptionSelect={onOptionSelect} />
219256
)
220257
})}
258+
{(() => {
259+
const reassembled = parsed.segments
260+
.map((s) => {
261+
if (s.type === 'workspace_resource') {
262+
const label = s.data.title || s.data.id
263+
return `[${label}](#wsres-${s.data.type}-${s.data.id})`
264+
}
265+
if (s.type === 'text' || s.type === 'thinking') return s.content
266+
return ''
267+
})
268+
.join('')
269+
if (!reassembled.trim()) return null
270+
return (
271+
<div className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}>
272+
<Streamdown mode='static' components={MARKDOWN_COMPONENTS}>
273+
{reassembled}
274+
</Streamdown>
275+
</div>
276+
)
277+
})()}
221278
{parsed.hasPendingTag && isStreaming && <PendingTagIndicator />}
222279
</div>
223280
)

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type {
99
RuntimeSpecialTagName,
1010
UsageUpgradeAction,
1111
UsageUpgradeTagData,
12+
WorkspaceResourceTagData,
13+
WorkspaceResourceTagType,
1214
} from './special-tags'
1315
export {
1416
CREDENTIAL_TAG_TYPES,
@@ -20,4 +22,6 @@ export {
2022
parseTextTagBody,
2123
SpecialTags,
2224
USAGE_UPGRADE_ACTIONS,
25+
WORKSPACE_RESOURCE_TAG_TYPES,
26+
WorkspaceResourceDisplay,
2327
} from './special-tags'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
'use client'
22

3-
import { createElement, useState } from 'react'
3+
import { createElement, useMemo, useState } from 'react'
44
import { useParams } from 'next/navigation'
55
import { ArrowRight, ChevronDown, Expandable, ExpandableContent } from '@/components/emcn'
66
import { cn } from '@/lib/core/utils/cn'
77
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
8+
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
9+
import type {
10+
ChatMessageContext,
11+
MothershipResource,
12+
} from '@/app/workspace/[workspaceId]/home/types'
13+
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
14+
import { useTablesList } from '@/hooks/queries/tables'
15+
import { useWorkflows } from '@/hooks/queries/workflows'
16+
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
817

918
export interface OptionsItemData {
1019
title: string
@@ -55,20 +64,32 @@ export interface FileTagData {
5564
content: string
5665
}
5766

67+
export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const
68+
69+
export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number]
70+
71+
export interface WorkspaceResourceTagData {
72+
type: WorkspaceResourceTagType
73+
id: string
74+
title?: string
75+
}
76+
5877
export type ContentSegment =
5978
| { type: 'text'; content: string }
6079
| { type: 'thinking'; content: string }
6180
| { type: 'options'; data: OptionsTagData }
6281
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
6382
| { type: 'credential'; data: CredentialTagData }
6483
| { type: 'mothership-error'; data: MothershipErrorTagData }
84+
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
6585

6686
export type RuntimeSpecialTagName =
6787
| 'thinking'
6888
| 'options'
6989
| 'credential'
7090
| 'mothership-error'
7191
| 'file'
92+
| 'workspace_resource'
7293

7394
export interface ParsedSpecialContent {
7495
segments: ContentSegment[]
@@ -81,6 +102,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [
81102
'credential',
82103
'mothership-error',
83104
'file',
105+
'workspace_resource',
84106
] as const
85107

86108
const SPECIAL_TAG_NAMES = [
@@ -89,6 +111,7 @@ const SPECIAL_TAG_NAMES = [
89111
'usage_upgrade',
90112
'credential',
91113
'mothership-error',
114+
'workspace_resource',
92115
] as const
93116

94117
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -134,6 +157,16 @@ function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagDa
134157
)
135158
}
136159

160+
function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceTagData {
161+
if (!isRecord(value)) return false
162+
return (
163+
typeof value.type === 'string' &&
164+
(WORKSPACE_RESOURCE_TAG_TYPES as readonly string[]).includes(value.type) &&
165+
typeof value.id === 'string' &&
166+
value.id.trim().length > 0
167+
)
168+
}
169+
137170
export function parseJsonTagBody<T>(
138171
body: string,
139172
isExpectedShape: (value: unknown) => value is T
@@ -181,6 +214,7 @@ function parseSpecialTagData(
181214
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
182215
| { type: 'credential'; data: CredentialTagData }
183216
| { type: 'mothership-error'; data: MothershipErrorTagData }
217+
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
184218
| null {
185219
if (tagName === 'thinking') {
186220
const content = parseTextTagBody(body)
@@ -207,11 +241,16 @@ function parseSpecialTagData(
207241
return data ? { type: 'mothership-error', data } : null
208242
}
209243

244+
if (tagName === 'workspace_resource') {
245+
const data = parseJsonTagBody(body, isWorkspaceResourceTagData)
246+
return data ? { type: 'workspace_resource', data } : null
247+
}
248+
210249
return null
211250
}
212251

213252
/**
214-
* Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
253+
* Parses inline special tags (`<options>`, `<usage_upgrade>`, `<workspace_resource>`) from streamed
215254
* text content. Complete tags are extracted into typed segments; incomplete
216255
* tags (still streaming) are suppressed from display and flagged via
217256
* `hasPendingTag` so the caller can show a loading indicator.
@@ -307,12 +346,18 @@ const THINKING_BLOCKS = [
307346
interface SpecialTagsProps {
308347
segment: Exclude<ContentSegment, { type: 'text' }>
309348
onOptionSelect?: (id: string) => void
349+
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
310350
}
311351

312352
/**
313-
* Unified renderer for inline special tags: `<options>`, `<usage_upgrade>`, and `<credential>`.
353+
* Unified renderer for inline special tags: `<options>`, `<usage_upgrade>`, `<credential>`,
354+
* and `<workspace_resource>`.
314355
*/
315-
export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) {
356+
export function SpecialTags({
357+
segment,
358+
onOptionSelect,
359+
onWorkspaceResourceSelect,
360+
}: SpecialTagsProps) {
316361
switch (segment.type) {
317362
case 'thinking':
318363
return null
@@ -324,6 +369,8 @@ export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) {
324369
return <CredentialDisplay data={segment.data} />
325370
case 'mothership-error':
326371
return <MothershipErrorDisplay data={segment.data} />
372+
case 'workspace_resource':
373+
return <WorkspaceResourceDisplay data={segment.data} onSelect={onWorkspaceResourceSelect} />
327374
default:
328375
return null
329376
}
@@ -413,6 +460,102 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
413460
)
414461
}
415462

463+
function fallbackWorkspaceResourceTitle(type: WorkspaceResourceTagType): string {
464+
switch (type) {
465+
case 'workflow':
466+
return 'Workflow'
467+
case 'table':
468+
return 'Table'
469+
case 'file':
470+
return 'File'
471+
}
472+
}
473+
474+
function toMothershipResourceType(type: WorkspaceResourceTagType): MothershipResource['type'] {
475+
return type
476+
}
477+
478+
function toChatMessageContext(data: WorkspaceResourceTagData, label: string): ChatMessageContext {
479+
switch (data.type) {
480+
case 'workflow':
481+
return { kind: 'workflow', label, workflowId: data.id }
482+
case 'table':
483+
return { kind: 'table', label, tableId: data.id }
484+
case 'file':
485+
return { kind: 'file', label, fileId: data.id }
486+
}
487+
}
488+
489+
export function WorkspaceResourceDisplay({
490+
data,
491+
onSelect,
492+
}: {
493+
data: WorkspaceResourceTagData
494+
onSelect?: (resource: MothershipResource) => void
495+
}) {
496+
const { workspaceId } = useParams<{ workspaceId: string }>()
497+
const { data: workflows = [] } = useWorkflows(workspaceId)
498+
const { data: tables = [] } = useTablesList(workspaceId)
499+
const { data: files = [] } = useWorkspaceFiles(workspaceId)
500+
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId)
501+
502+
const resource = useMemo<MothershipResource>(() => {
503+
const title =
504+
data.type === 'workflow'
505+
? (workflows.find((workflow) => workflow.id === data.id)?.name ??
506+
fallbackWorkspaceResourceTitle(data.type))
507+
: data.type === 'table'
508+
? (tables.find((table) => table.id === data.id)?.name ??
509+
fallbackWorkspaceResourceTitle(data.type))
510+
: data.type === 'file'
511+
? (files.find((file) => file.id === data.id)?.name ??
512+
fallbackWorkspaceResourceTitle(data.type))
513+
: (knowledgeBases.find((knowledgeBase) => knowledgeBase.id === data.id)?.name ??
514+
fallbackWorkspaceResourceTitle(data.type))
515+
516+
return {
517+
type: toMothershipResourceType(data.type),
518+
id: data.id,
519+
title,
520+
}
521+
}, [data.id, data.type, files, knowledgeBases, tables, workflows])
522+
523+
const context = useMemo(() => toChatMessageContext(data, resource.title), [data, resource.title])
524+
525+
const workflowColor = useMemo(() => {
526+
if (data.type !== 'workflow') return null
527+
return workflows.find((workflow) => workflow.id === data.id)?.color ?? null
528+
}, [data.id, data.type, workflows])
529+
530+
const mentionContent = (
531+
<>
532+
<ContextMentionIcon
533+
context={context}
534+
workflowColor={workflowColor}
535+
className='relative top-0.5 h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
536+
/>
537+
{resource.title}
538+
</>
539+
)
540+
541+
const classes =
542+
'inline-flex items-baseline gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] align-baseline font-[inherit] text-[inherit] leading-[inherit]'
543+
544+
if (!onSelect) {
545+
return <span className={classes}>{mentionContent}</span>
546+
}
547+
548+
return (
549+
<button
550+
type='button'
551+
onClick={() => onSelect(resource)}
552+
className={cn(classes, 'cursor-pointer transition-colors hover-hover:bg-[var(--surface-6)]')}
553+
>
554+
{mentionContent}
555+
</button>
556+
)
557+
}
558+
416559
function getCredentialIcon(provider: string): React.ComponentType<{ className?: string }> | null {
417560
const lower = provider.toLowerCase()
418561

0 commit comments

Comments
 (0)