Skip to content

Commit 8a4a80b

Browse files
committed
fix(docs): defer the Ask Sim chat widget's heavy deps until opened
The chat panel (useChat from @ai-sdk/react, Streamdown + its CSS) was mounted unconditionally in the root layout on every single page, so its full weight loaded and executed even though the widget starts closed on every page view. Traced via the LCP breakdown insight under real devtools CPU/network throttling: the LCP text element (the intro paragraph) had a ~8s element render delay despite a ~13ms TTFB, and bootup-time attributed ~4.3s of scripting time to a single chunk containing React/ReactDOM's own runtime plus this widget's eagerly-bundled dependencies. Split into a lightweight ask-ai.tsx (just the toggle button + open state) and ask-ai-panel.tsx (the actual chat UI, useChat, Streamdown), loaded via next/dynamic(..., { ssr: false }) only when the user opens the widget. Verified: the panel's chunk now has zero network requests on initial page load. Measured (mobile, devtools throttling, /introduction): - Performance: 69 -> 75 - LCP: 8.0s -> 6.4s - TBT: 260ms -> 130ms The remaining ~6.4s LCP delay traces to the same shared chunk, now identified as core React/ReactDOM hydration cost for this page's sidebar/TOC/breadcrumb tree rather than an isolated bug - a real, larger initiative (hydration architecture, not a surgical fix), documented here rather than rushed.
1 parent e31e684 commit 8a4a80b

2 files changed

Lines changed: 252 additions & 215 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
'use client'
2+
3+
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
4+
import { useChat } from '@ai-sdk/react'
5+
import { DefaultChatTransport } from 'ai'
6+
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
7+
import { Streamdown } from 'streamdown'
8+
import { cn } from '@/lib/utils'
9+
import 'streamdown/styles.css'
10+
11+
interface DocSource {
12+
title: string
13+
url: string
14+
}
15+
16+
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
17+
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
18+
const seen = new Set<string>()
19+
const sources: DocSource[] = []
20+
21+
for (const part of parts) {
22+
if (part.type !== 'tool-searchDocs') continue
23+
const output = (part as { output?: unknown }).output
24+
if (!Array.isArray(output)) continue
25+
for (const item of output as DocSource[]) {
26+
if (!item?.url || seen.has(item.url)) continue
27+
seen.add(item.url)
28+
sources.push({ title: item.title, url: item.url })
29+
}
30+
}
31+
32+
return sources
33+
}
34+
35+
/** Concatenate the streamed text parts of a message. */
36+
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
37+
return parts
38+
.filter((part) => part.type === 'text')
39+
.map((part) => (part as unknown as { text: string }).text)
40+
.join('')
41+
}
42+
43+
interface AskAIPanelProps {
44+
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
45+
locale: string
46+
onClose: () => void
47+
}
48+
49+
export function AskAIPanel({ locale, onClose }: AskAIPanelProps) {
50+
const [input, setInput] = useState('')
51+
const scrollRef = useRef<HTMLDivElement>(null)
52+
const textareaRef = useRef<HTMLTextAreaElement>(null)
53+
54+
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), [])
55+
56+
const { messages, sendMessage, status, stop, error } = useChat({ transport })
57+
58+
const isBusy = status === 'submitted' || status === 'streaming'
59+
60+
const handleClose = () => {
61+
stop()
62+
onClose()
63+
}
64+
65+
useEffect(() => {
66+
textareaRef.current?.focus()
67+
const handleKeyDown = (event: KeyboardEvent) => {
68+
if (event.key === 'Escape') {
69+
handleClose()
70+
}
71+
}
72+
document.addEventListener('keydown', handleKeyDown)
73+
return () => document.removeEventListener('keydown', handleKeyDown)
74+
}, [])
75+
76+
useEffect(() => {
77+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
78+
}, [])
79+
80+
useEffect(() => {
81+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
82+
}, [messages])
83+
84+
const handleSubmit = (event: FormEvent) => {
85+
event.preventDefault()
86+
const text = input.trim()
87+
if (!text || isBusy) return
88+
sendMessage({ text }, { body: { locale } })
89+
setInput('')
90+
}
91+
92+
return (
93+
<div
94+
role='dialog'
95+
aria-label='Ask Sim'
96+
className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'
97+
>
98+
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
99+
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
100+
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
101+
Ask Sim
102+
</span>
103+
<button
104+
type='button'
105+
aria-label='Close'
106+
onClick={handleClose}
107+
className='flex size-7 items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
108+
>
109+
<X className='size-[16px]' />
110+
</button>
111+
</div>
112+
113+
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
114+
{messages.length === 0 && (
115+
<p className='text-[var(--text-muted)] text-sm'>
116+
Ask anything about building, deploying, and managing AI agents in Sim.
117+
</p>
118+
)}
119+
120+
{messages.map((message, index) => {
121+
const text = getText(message.parts)
122+
const isStreaming = isBusy && index === messages.length - 1
123+
const sources = message.role === 'assistant' ? getSources(message.parts) : []
124+
return (
125+
<div
126+
key={message.id}
127+
className={cn(
128+
'flex flex-col gap-1.5',
129+
message.role === 'user' ? 'items-end' : 'items-start'
130+
)}
131+
>
132+
{message.role === 'user' ? (
133+
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
134+
{text}
135+
</div>
136+
) : (
137+
<div className='max-w-full text-[var(--text-primary)] text-base'>
138+
{text ? (
139+
<Streamdown
140+
className={cn(
141+
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
142+
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
143+
'[&_strong]:font-[600]',
144+
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
145+
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
146+
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
147+
)}
148+
>
149+
{text}
150+
</Streamdown>
151+
) : isStreaming ? (
152+
'…'
153+
) : sources.length === 0 ? (
154+
<span className='text-[var(--text-muted)]'>No answer returned.</span>
155+
) : null}
156+
</div>
157+
)}
158+
{sources.length > 0 && (
159+
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
160+
{sources.map((source) => (
161+
<a
162+
key={source.url}
163+
href={source.url}
164+
target='_blank'
165+
rel='noopener noreferrer'
166+
className='rounded-lg border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
167+
>
168+
{source.title || source.url}
169+
</a>
170+
))}
171+
</div>
172+
)}
173+
</div>
174+
)
175+
})}
176+
177+
{error && (
178+
<p className='text-[var(--text-muted)] text-sm'>Something went wrong. Please try again.</p>
179+
)}
180+
</div>
181+
182+
<form onSubmit={handleSubmit} className='px-3 pb-3'>
183+
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
184+
<textarea
185+
ref={textareaRef}
186+
aria-label='Ask Sim about the docs'
187+
value={input}
188+
onChange={(event) => setInput(event.target.value)}
189+
onKeyDown={(event) => {
190+
if (event.key === 'Enter' && !event.shiftKey) {
191+
event.preventDefault()
192+
handleSubmit(event)
193+
}
194+
}}
195+
rows={1}
196+
placeholder='Ask Sim about the docs…'
197+
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
198+
/>
199+
{isBusy ? (
200+
<button
201+
type='button'
202+
aria-label='Stop'
203+
onClick={() => stop()}
204+
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
205+
>
206+
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
207+
</button>
208+
) : (
209+
<button
210+
type='submit'
211+
aria-label='Send'
212+
disabled={!input.trim()}
213+
className={cn(
214+
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
215+
input.trim()
216+
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
217+
: 'bg-[#808080]'
218+
)}
219+
>
220+
<ArrowUp className='size-[16px] text-white dark:text-black' />
221+
</button>
222+
)}
223+
</div>
224+
</form>
225+
</div>
226+
)
227+
}

0 commit comments

Comments
 (0)