Skip to content

Commit 00eb812

Browse files
committed
improvement: panel, special tags
1 parent 75d2dab commit 00eb812

File tree

8 files changed

+396
-15
lines changed

8 files changed

+396
-15
lines changed

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

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import 'prismjs/components/prism-markup'
1010
import '@/components/emcn/components/code/code.css'
1111
import { Checkbox, highlight, languages } from '@/components/emcn'
1212
import { cn } from '@/lib/core/utils/cn'
13+
import {
14+
PendingTagIndicator,
15+
parseSpecialTags,
16+
SpecialTags,
17+
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
1318
import { useStreamingReveal } from '@/app/workspace/[workspaceId]/home/hooks/use-streaming-reveal'
1419
import { useThrottledValue } from '@/hooks/use-throttled-value'
1520

@@ -179,14 +184,23 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
179184
interface ChatContentProps {
180185
content: string
181186
isStreaming?: boolean
187+
onOptionSelect?: (id: string) => void
182188
}
183189

184190
const STREAMING_THROTTLE_MS = 50
185191

186-
export function ChatContent({ content, isStreaming = false }: ChatContentProps) {
192+
export function ChatContent({ content, isStreaming = false, onOptionSelect }: ChatContentProps) {
187193
const throttled = useThrottledValue(content, isStreaming ? STREAMING_THROTTLE_MS : undefined)
188194
const rendered = isStreaming ? throttled : content
189-
const { committed, incoming, generation } = useStreamingReveal(rendered, isStreaming)
195+
196+
const parsed = useMemo(() => parseSpecialTags(rendered, isStreaming), [rendered, isStreaming])
197+
const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text')
198+
199+
const plainText = hasSpecialContent ? '' : rendered
200+
const { committed, incoming, generation } = useStreamingReveal(
201+
plainText,
202+
!hasSpecialContent && isStreaming
203+
)
190204

191205
const committedMarkdown = useMemo(
192206
() =>
@@ -198,6 +212,28 @@ export function ChatContent({ content, isStreaming = false }: ChatContentProps)
198212
[committed]
199213
)
200214

215+
if (hasSpecialContent) {
216+
return (
217+
<div className='space-y-3'>
218+
{parsed.segments.map((segment, i) => {
219+
if (segment.type === 'text') {
220+
return (
221+
<div key={`text-${i}`} className={PROSE_CLASSES}>
222+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
223+
{segment.content}
224+
</ReactMarkdown>
225+
</div>
226+
)
227+
}
228+
return (
229+
<SpecialTags key={`special-${i}`} segment={segment} onOptionSelect={onOptionSelect} />
230+
)
231+
})}
232+
{parsed.hasPendingTag && isStreaming && <PendingTagIndicator />}
233+
</div>
234+
)
235+
}
236+
201237
return (
202238
<div className={PROSE_CLASSES}>
203239
{committedMarkdown}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { AgentGroup } from './agent-group'
22
export { ChatContent } from './chat-content'
33
export { Options } from './options'
4+
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type {
2+
ContentSegment,
3+
CredentialTagData,
4+
OptionsTagData,
5+
ParsedSpecialContent,
6+
UsageUpgradeTagData,
7+
} from './special-tags'
8+
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
'use client'
2+
3+
import { ArrowUpRight } from 'lucide-react'
4+
import { useParams } from 'next/navigation'
5+
import { ArrowRight } from '@/components/emcn'
6+
import { cn } from '@/lib/core/utils/cn'
7+
8+
export interface OptionsItemData {
9+
title: string
10+
description?: string
11+
}
12+
13+
export type OptionsTagData = Record<string, OptionsItemData | string>
14+
15+
export interface UsageUpgradeTagData {
16+
reason: string
17+
action: 'upgrade_plan' | 'increase_limit'
18+
message: string
19+
}
20+
21+
export interface CredentialTagData {
22+
link: string
23+
provider: string
24+
}
25+
26+
export type ContentSegment =
27+
| { type: 'text'; content: string }
28+
| { type: 'options'; data: OptionsTagData }
29+
| { type: 'usage_upgrade'; data: UsageUpgradeTagData }
30+
| { type: 'credential'; data: CredentialTagData }
31+
32+
export interface ParsedSpecialContent {
33+
segments: ContentSegment[]
34+
hasPendingTag: boolean
35+
}
36+
37+
const SPECIAL_TAG_NAMES = ['options', 'usage_upgrade', 'credential'] as const
38+
39+
/**
40+
* Parses inline special tags (`<options>`, `<usage_upgrade>`) from streamed
41+
* text content. Complete tags are extracted into typed segments; incomplete
42+
* tags (still streaming) are suppressed from display and flagged via
43+
* `hasPendingTag` so the caller can show a loading indicator.
44+
*
45+
* Trailing partial opening tags (e.g. `<opt`, `<usage_`) are also stripped
46+
* during streaming to prevent flashing raw markup.
47+
*/
48+
export function parseSpecialTags(content: string, isStreaming: boolean): ParsedSpecialContent {
49+
const segments: ContentSegment[] = []
50+
let hasPendingTag = false
51+
let cursor = 0
52+
53+
while (cursor < content.length) {
54+
let nearestStart = -1
55+
let nearestTagName = ''
56+
57+
for (const name of SPECIAL_TAG_NAMES) {
58+
const idx = content.indexOf(`<${name}>`, cursor)
59+
if (idx !== -1 && (nearestStart === -1 || idx < nearestStart)) {
60+
nearestStart = idx
61+
nearestTagName = name
62+
}
63+
}
64+
65+
if (nearestStart === -1) {
66+
let remaining = content.slice(cursor)
67+
68+
if (isStreaming) {
69+
const partial = remaining.match(/<[a-z_]*$/i)
70+
if (partial) {
71+
const fragment = partial[0].slice(1)
72+
if (fragment.length > 0 && SPECIAL_TAG_NAMES.some((t) => t.startsWith(fragment))) {
73+
remaining = remaining.slice(0, -partial[0].length)
74+
hasPendingTag = true
75+
}
76+
}
77+
}
78+
79+
if (remaining.trim()) {
80+
segments.push({ type: 'text', content: remaining })
81+
}
82+
break
83+
}
84+
85+
if (nearestStart > cursor) {
86+
const text = content.slice(cursor, nearestStart)
87+
if (text.trim()) {
88+
segments.push({ type: 'text', content: text })
89+
}
90+
}
91+
92+
const openTag = `<${nearestTagName}>`
93+
const closeTag = `</${nearestTagName}>`
94+
const bodyStart = nearestStart + openTag.length
95+
const closeIdx = content.indexOf(closeTag, bodyStart)
96+
97+
if (closeIdx === -1) {
98+
hasPendingTag = true
99+
cursor = content.length
100+
break
101+
}
102+
103+
const body = content.slice(bodyStart, closeIdx)
104+
try {
105+
const data = JSON.parse(body)
106+
segments.push({ type: nearestTagName as 'options' | 'usage_upgrade' | 'credential', data })
107+
} catch {
108+
/* malformed JSON — drop the tag silently */
109+
}
110+
111+
cursor = closeIdx + closeTag.length
112+
}
113+
114+
if (segments.length === 0 && !hasPendingTag) {
115+
segments.push({ type: 'text', content })
116+
}
117+
118+
return { segments, hasPendingTag }
119+
}
120+
121+
const THINKING_BLOCKS = [
122+
{ color: '#2ABBF8', delay: '0s' },
123+
{ color: '#00F701', delay: '0.2s' },
124+
{ color: '#FA4EDF', delay: '0.6s' },
125+
{ color: '#FFCC02', delay: '0.4s' },
126+
] as const
127+
128+
interface SpecialTagsProps {
129+
segment: Exclude<ContentSegment, { type: 'text' }>
130+
onOptionSelect?: (id: string) => void
131+
}
132+
133+
/**
134+
* Unified renderer for inline special tags: `<options>`, `<usage_upgrade>`, and `<credential>`.
135+
*/
136+
export function SpecialTags({ segment, onOptionSelect }: SpecialTagsProps) {
137+
switch (segment.type) {
138+
case 'options':
139+
return <OptionsDisplay data={segment.data} onSelect={onOptionSelect} />
140+
case 'usage_upgrade':
141+
return <UsageUpgradeDisplay data={segment.data} />
142+
case 'credential':
143+
return <CredentialDisplay data={segment.data} />
144+
default:
145+
return null
146+
}
147+
}
148+
149+
/**
150+
* Renders a "Thinking" shimmer while a special tag is still streaming in.
151+
*/
152+
export function PendingTagIndicator() {
153+
return (
154+
<div className='flex animate-stream-fade-in items-center gap-[8px] py-[8px]'>
155+
<div className='grid h-[16px] w-[16px] grid-cols-2 gap-[1.5px]'>
156+
{THINKING_BLOCKS.map((block, i) => (
157+
<div
158+
key={i}
159+
className='animate-thinking-block rounded-[2px]'
160+
style={{ backgroundColor: block.color, animationDelay: block.delay }}
161+
/>
162+
))}
163+
</div>
164+
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
165+
Thinking…
166+
</span>
167+
</div>
168+
)
169+
}
170+
171+
interface OptionsDisplayProps {
172+
data: OptionsTagData
173+
onSelect?: (id: string) => void
174+
}
175+
176+
function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
177+
const entries = Object.entries(data)
178+
if (entries.length === 0) return null
179+
180+
return (
181+
<div className='animate-stream-fade-in'>
182+
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
183+
Suggested follow-ups
184+
</span>
185+
<div className='mt-1.5 flex flex-col'>
186+
{entries.map(([key, value], i) => {
187+
const title = typeof value === 'string' ? value : value.title
188+
189+
return (
190+
<button
191+
key={key}
192+
type='button'
193+
onClick={() => onSelect?.(key)}
194+
className={cn(
195+
'flex items-center gap-[8px] border-[var(--divider)] px-[8px] py-[8px] text-left transition-colors hover:bg-[var(--surface-5)]',
196+
i > 0 && 'border-t'
197+
)}
198+
>
199+
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
200+
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-icon)]'>
201+
{i + 1}
202+
</span>
203+
</div>
204+
<span className='flex-1 font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
205+
{title}
206+
</span>
207+
<ArrowRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
208+
</button>
209+
)
210+
})}
211+
</div>
212+
</div>
213+
)
214+
}
215+
216+
function CredentialDisplay({ data }: { data: CredentialTagData }) {
217+
return (
218+
<a
219+
href={data.link}
220+
target='_blank'
221+
rel='noopener noreferrer'
222+
className='flex animate-stream-fade-in items-center gap-[8px] rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover:bg-[var(--surface-5)]'
223+
>
224+
<svg
225+
className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]'
226+
viewBox='0 0 16 16'
227+
fill='none'
228+
xmlns='http://www.w3.org/2000/svg'
229+
>
230+
<rect x='2' y='5' width='12' height='8' rx='1.5' stroke='currentColor' strokeWidth='1.3' />
231+
<path
232+
d='M5 5V3.5a3 3 0 1 1 6 0V5'
233+
stroke='currentColor'
234+
strokeWidth='1.3'
235+
strokeLinecap='round'
236+
/>
237+
<circle cx='8' cy='9.5' r='1.25' fill='currentColor' />
238+
</svg>
239+
<span className='flex-1 font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
240+
Connect {data.provider}
241+
</span>
242+
<ArrowUpRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
243+
</a>
244+
)
245+
}
246+
247+
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
248+
const { workspaceId } = useParams<{ workspaceId: string }>()
249+
const settingsPath = `/workspace/${workspaceId}/settings/subscription`
250+
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
251+
252+
return (
253+
<div className='animate-stream-fade-in rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3 dark:border-amber-500/20 dark:bg-amber-950/20'>
254+
<div className='flex items-center gap-2'>
255+
<svg
256+
className='h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400'
257+
viewBox='0 0 16 16'
258+
fill='none'
259+
xmlns='http://www.w3.org/2000/svg'
260+
>
261+
<path
262+
d='M8 1.5L1 14h14L8 1.5z'
263+
stroke='currentColor'
264+
strokeWidth='1.3'
265+
strokeLinejoin='round'
266+
/>
267+
<path d='M8 6.5v3' stroke='currentColor' strokeWidth='1.3' strokeLinecap='round' />
268+
<circle cx='8' cy='11.5' r='0.75' fill='currentColor' />
269+
</svg>
270+
<span className='font-[500] text-[14px] text-amber-800 leading-5 dark:text-amber-300'>
271+
Usage Limit Reached
272+
</span>
273+
</div>
274+
<p className='mt-1.5 text-[13px] text-amber-700/90 leading-[20px] dark:text-amber-400/80'>
275+
{data.message}
276+
</p>
277+
<a
278+
href={settingsPath}
279+
className='mt-2 inline-flex items-center gap-1 font-[500] text-[13px] text-amber-700 underline decoration-dashed underline-offset-2 transition-colors hover:text-amber-900 dark:text-amber-300 dark:hover:text-amber-200'
280+
>
281+
{buttonLabel}
282+
<ArrowUpRight className='h-3 w-3' />
283+
</a>
284+
</div>
285+
)
286+
}

0 commit comments

Comments
 (0)