Skip to content

Commit 75f8c6a

Browse files
TheodoreSpeaksTheodore Li
andauthored
fix(ui): persist active resource tab in url, fix internal markdown links (#3925)
* fix(ui): handle markdown internal links * Fix lint * Reference correct scroll container * Add resource tab to url state, scroll correctly on new tab * Handle delete all resource by clearing url --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent c2b12cf commit 75f8c6a

File tree

3 files changed

+173
-44
lines changed

3 files changed

+173
-44
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

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

3-
import { createContext, memo, useContext, useMemo, useRef } from 'react'
3+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
4+
import { useRouter } from 'next/navigation'
45
import type { Components, ExtraProps } from 'react-markdown'
56
import ReactMarkdown from 'react-markdown'
7+
import rehypeSlug from 'rehype-slug'
68
import remarkBreaks from 'remark-breaks'
79
import remarkGfm from 'remark-gfm'
810
import { Checkbox } from '@/components/emcn'
@@ -70,6 +72,7 @@ export const PreviewPanel = memo(function PreviewPanel({
7072
})
7173

7274
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
75+
const REHYPE_PLUGINS = [rehypeSlug]
7376

7477
/**
7578
* Carries the contentRef and toggle handler from MarkdownPreview down to the
@@ -83,29 +86,43 @@ const MarkdownCheckboxCtx = createContext<{
8386
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
8487
const CheckboxIndexCtx = createContext(-1)
8588

89+
const NavigateCtx = createContext<((path: string) => void) | null>(null)
90+
8691
const STATIC_MARKDOWN_COMPONENTS = {
8792
p: ({ children }: { children?: React.ReactNode }) => (
8893
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
8994
{children}
9095
</p>
9196
),
92-
h1: ({ children }: { children?: React.ReactNode }) => (
93-
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
97+
h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
98+
<h1
99+
id={id}
100+
className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'
101+
>
94102
{children}
95103
</h1>
96104
),
97-
h2: ({ children }: { children?: React.ReactNode }) => (
98-
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
105+
h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
106+
<h2
107+
id={id}
108+
className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'
109+
>
99110
{children}
100111
</h2>
101112
),
102-
h3: ({ children }: { children?: React.ReactNode }) => (
103-
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
113+
h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
114+
<h3
115+
id={id}
116+
className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'
117+
>
104118
{children}
105119
</h3>
106120
),
107-
h4: ({ children }: { children?: React.ReactNode }) => (
108-
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
121+
h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
122+
<h4
123+
id={id}
124+
className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'
125+
>
109126
{children}
110127
</h4>
111128
),
@@ -138,16 +155,6 @@ const STATIC_MARKDOWN_COMPONENTS = {
138155
)
139156
},
140157
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
141-
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
142-
<a
143-
href={href}
144-
target='_blank'
145-
rel='noopener noreferrer'
146-
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
147-
>
148-
{children}
149-
</a>
150-
),
151158
strong: ({ children }: { children?: React.ReactNode }) => (
152159
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
153160
),
@@ -267,8 +274,75 @@ function InputRenderer({
267274
)
268275
}
269276

277+
function isInternalHref(
278+
href: string,
279+
origin = window.location.origin
280+
): { pathname: string; hash: string } | null {
281+
if (href.startsWith('#')) return { pathname: '', hash: href }
282+
try {
283+
const url = new URL(href, origin)
284+
if (url.origin === origin && url.pathname.startsWith('/workspace/')) {
285+
return { pathname: url.pathname, hash: url.hash }
286+
}
287+
} catch {
288+
if (href.startsWith('/workspace/')) {
289+
const hashIdx = href.indexOf('#')
290+
if (hashIdx === -1) return { pathname: href, hash: '' }
291+
return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) }
292+
}
293+
}
294+
return null
295+
}
296+
297+
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
298+
const navigate = useContext(NavigateCtx)
299+
const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href])
300+
301+
const handleClick = useCallback(
302+
(e: React.MouseEvent<HTMLAnchorElement>) => {
303+
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
304+
305+
e.preventDefault()
306+
307+
if (parsed.pathname === '' && parsed.hash) {
308+
const el = document.getElementById(parsed.hash.slice(1))
309+
if (el) {
310+
const container = el.closest('.overflow-auto') as HTMLElement | null
311+
if (container) {
312+
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
313+
} else {
314+
el.scrollIntoView({ behavior: 'smooth' })
315+
}
316+
}
317+
return
318+
}
319+
320+
const destination = parsed.pathname + parsed.hash
321+
if (navigate) {
322+
navigate(destination)
323+
} else {
324+
window.location.assign(destination)
325+
}
326+
},
327+
[parsed, navigate]
328+
)
329+
330+
return (
331+
<a
332+
href={href}
333+
target={parsed ? undefined : '_blank'}
334+
rel={parsed ? undefined : 'noopener noreferrer'}
335+
onClick={handleClick}
336+
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
337+
>
338+
{children}
339+
</a>
340+
)
341+
}
342+
270343
const MARKDOWN_COMPONENTS = {
271344
...STATIC_MARKDOWN_COMPONENTS,
345+
a: AnchorRenderer,
272346
ul: UlRenderer,
273347
ol: OlRenderer,
274348
li: LiRenderer,
@@ -284,6 +358,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
284358
isStreaming?: boolean
285359
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
286360
}) {
361+
const { push: navigate } = useRouter()
287362
const { ref: scrollRef } = useAutoScroll(isStreaming)
288363
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
289364

@@ -295,10 +370,30 @@ const MarkdownPreview = memo(function MarkdownPreview({
295370
[onCheckboxToggle]
296371
)
297372

373+
const hasScrolledToHash = useRef(false)
374+
useEffect(() => {
375+
const hash = window.location.hash
376+
if (!hash || hasScrolledToHash.current) return
377+
const id = hash.slice(1)
378+
const el = document.getElementById(id)
379+
if (!el) return
380+
hasScrolledToHash.current = true
381+
const container = el.closest('.overflow-auto') as HTMLElement | null
382+
if (container) {
383+
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
384+
} else {
385+
el.scrollIntoView({ behavior: 'smooth' })
386+
}
387+
}, [content])
388+
298389
const committedMarkdown = useMemo(
299390
() =>
300391
committed ? (
301-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
392+
<ReactMarkdown
393+
remarkPlugins={REMARK_PLUGINS}
394+
rehypePlugins={REHYPE_PLUGINS}
395+
components={MARKDOWN_COMPONENTS}
396+
>
302397
{committed}
303398
</ReactMarkdown>
304399
) : null,
@@ -307,30 +402,42 @@ const MarkdownPreview = memo(function MarkdownPreview({
307402

308403
if (onCheckboxToggle) {
309404
return (
310-
<MarkdownCheckboxCtx.Provider value={ctxValue}>
311-
<div ref={scrollRef} className='h-full overflow-auto p-6'>
312-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
313-
{content}
314-
</ReactMarkdown>
315-
</div>
316-
</MarkdownCheckboxCtx.Provider>
405+
<NavigateCtx.Provider value={navigate}>
406+
<MarkdownCheckboxCtx.Provider value={ctxValue}>
407+
<div ref={scrollRef} className='h-full overflow-auto p-6'>
408+
<ReactMarkdown
409+
remarkPlugins={REMARK_PLUGINS}
410+
rehypePlugins={REHYPE_PLUGINS}
411+
components={MARKDOWN_COMPONENTS}
412+
>
413+
{content}
414+
</ReactMarkdown>
415+
</div>
416+
</MarkdownCheckboxCtx.Provider>
417+
</NavigateCtx.Provider>
317418
)
318419
}
319420

320421
return (
321-
<div ref={scrollRef} className='h-full overflow-auto p-6'>
322-
{committedMarkdown}
323-
{incoming && (
324-
<div
325-
key={generation}
326-
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
327-
>
328-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
329-
{incoming}
330-
</ReactMarkdown>
331-
</div>
332-
)}
333-
</div>
422+
<NavigateCtx.Provider value={navigate}>
423+
<div ref={scrollRef} className='h-full overflow-auto p-6'>
424+
{committedMarkdown}
425+
{incoming && (
426+
<div
427+
key={generation}
428+
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
429+
>
430+
<ReactMarkdown
431+
remarkPlugins={REMARK_PLUGINS}
432+
rehypePlugins={REHYPE_PLUGINS}
433+
components={MARKDOWN_COMPONENTS}
434+
>
435+
{incoming}
436+
</ReactMarkdown>
437+
</div>
438+
)}
439+
</div>
440+
</NavigateCtx.Provider>
334441
)
335442
})
336443

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { useParams, useRouter } from 'next/navigation'
5+
import { useParams, useRouter, useSearchParams } from 'next/navigation'
66
import { usePostHog } from 'posthog-js/react'
77
import { PanelLeft } from '@/components/emcn/icons'
88
import { useSession } from '@/lib/auth/auth-client'
@@ -28,6 +28,8 @@ interface HomeProps {
2828
export function Home({ chatId }: HomeProps = {}) {
2929
const { workspaceId } = useParams<{ workspaceId: string }>()
3030
const router = useRouter()
31+
const searchParams = useSearchParams()
32+
const initialResourceId = searchParams.get('resource')
3133
const { data: session } = useSession()
3234
const posthog = usePostHog()
3335
const posthogRef = useRef(posthog)
@@ -160,7 +162,10 @@ export function Home({ chatId }: HomeProps = {}) {
160162
} = useChat(
161163
workspaceId,
162164
chatId,
163-
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
165+
getMothershipUseChatOptions({
166+
onResourceEvent: handleResourceEvent,
167+
initialActiveResourceId: initialResourceId,
168+
})
164169
)
165170

166171
const [editingInputValue, setEditingInputValue] = useState('')
@@ -183,6 +188,16 @@ export function Home({ chatId }: HomeProps = {}) {
183188
[editQueuedMessage]
184189
)
185190

191+
useEffect(() => {
192+
const url = new URL(window.location.href)
193+
if (activeResourceId) {
194+
url.searchParams.set('resource', activeResourceId)
195+
} else {
196+
url.searchParams.delete('resource')
197+
}
198+
window.history.replaceState(null, '', url.toString())
199+
}, [activeResourceId])
200+
186201
useEffect(() => {
187202
wasSendingRef.current = false
188203
if (resolvedChatId) markRead(resolvedChatId)

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,11 @@ export interface UseChatOptions {
377377
onToolResult?: (toolName: string, success: boolean, result: unknown) => void
378378
onTitleUpdate?: () => void
379379
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
380+
initialActiveResourceId?: string | null
380381
}
381382

382383
export function getMothershipUseChatOptions(
383-
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
384+
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId'> = {}
384385
): UseChatOptions {
385386
return {
386387
apiPath: MOTHERSHIP_CHAT_API_PATH,
@@ -416,6 +417,7 @@ export function useChat(
416417
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
417418
const [resources, setResources] = useState<MothershipResource[]>([])
418419
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
420+
const initialActiveResourceIdRef = useRef(options?.initialActiveResourceId)
419421
const onResourceEventRef = useRef(options?.onResourceEvent)
420422
onResourceEventRef.current = options?.onResourceEvent
421423
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
@@ -845,7 +847,12 @@ export function useChat(
845847
const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file')
846848
if (persistedResources.length > 0) {
847849
setResources(persistedResources)
848-
setActiveResourceId(persistedResources[persistedResources.length - 1].id)
850+
const initialId = initialActiveResourceIdRef.current
851+
const restoredId =
852+
initialId && persistedResources.some((r) => r.id === initialId)
853+
? initialId
854+
: persistedResources[persistedResources.length - 1].id
855+
setActiveResourceId(restoredId)
849856

850857
for (const resource of persistedResources) {
851858
if (resource.type !== 'workflow') continue

0 commit comments

Comments
 (0)