From 16d73bad8c2d42168cafd6060f71603202a1a7b7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 3 Apr 2026 17:27:07 -0700 Subject: [PATCH 1/5] fix(ui): handle markdown internal links --- .../components/file-viewer/preview-panel.tsx | 134 ++++++++++++------ 1 file changed, 94 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 263498f86e..378154d1de 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1,10 +1,12 @@ 'use client' -import { createContext, memo, useContext, useMemo, useRef } from 'react' +import { createContext, memo, useCallback, useContext, useMemo, useRef } from 'react' import type { Components, ExtraProps } from 'react-markdown' import ReactMarkdown from 'react-markdown' import remarkBreaks from 'remark-breaks' +import rehypeSlug from 'rehype-slug' import remarkGfm from 'remark-gfm' +import { useRouter } from 'next/navigation' import { Checkbox } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -70,6 +72,7 @@ export const PreviewPanel = memo(function PreviewPanel({ }) const REMARK_PLUGINS = [remarkGfm, remarkBreaks] +const REHYPE_PLUGINS = [rehypeSlug] /** * Carries the contentRef and toggle handler from MarkdownPreview down to the @@ -83,29 +86,31 @@ const MarkdownCheckboxCtx = createContext<{ /** Carries the resolved checkbox index from LiRenderer to InputRenderer. */ const CheckboxIndexCtx = createContext(-1) +const NavigateCtx = createContext<((path: string) => void) | null>(null) + const STATIC_MARKDOWN_COMPONENTS = { p: ({ children }: { children?: React.ReactNode }) => (

{children}

), - h1: ({ children }: { children?: React.ReactNode }) => ( -

+ h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( +

{children}

), - h2: ({ children }: { children?: React.ReactNode }) => ( -

+ h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( +

{children}

), - h3: ({ children }: { children?: React.ReactNode }) => ( -

+ h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( +

{children}

), - h4: ({ children }: { children?: React.ReactNode }) => ( -

+ h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( +

{children}

), @@ -138,16 +143,6 @@ const STATIC_MARKDOWN_COMPONENTS = { ) }, pre: ({ children }: { children?: React.ReactNode }) => <>{children}, - a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( - - {children} - - ), strong: ({ children }: { children?: React.ReactNode }) => ( {children} ), @@ -267,8 +262,62 @@ function InputRenderer({ ) } +function isInternalHref(href: string): { pathname: string; hash: string } | null { + if (href.startsWith('#')) return { pathname: '', hash: href } + try { + const url = new URL(href, window.location.origin) + if (url.origin === window.location.origin) { + return { pathname: url.pathname, hash: url.hash } + } + } catch { + if (href.startsWith('/')) { + const hashIdx = href.indexOf('#') + if (hashIdx === -1) return { pathname: href, hash: '' } + return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) } + } + } + return null +} + +function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) { + const navigate = useContext(NavigateCtx) + const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href]) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + e.preventDefault() + + if (parsed.pathname === '' && parsed.hash) { + const el = document.getElementById(parsed.hash.slice(1)) + el?.scrollIntoView({ behavior: 'smooth' }) + return + } + + if (navigate) { + navigate(parsed.pathname + parsed.hash) + } + }, + [parsed, navigate] + ) + + return ( + + {children} + + ) +} + const MARKDOWN_COMPONENTS = { ...STATIC_MARKDOWN_COMPONENTS, + a: AnchorRenderer, ul: UlRenderer, ol: OlRenderer, li: LiRenderer, @@ -284,6 +333,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ isStreaming?: boolean onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void }) { + const { push: navigate } = useRouter() const { ref: scrollRef } = useAutoScroll(isStreaming) const { committed, incoming, generation } = useStreamingReveal(content, isStreaming) @@ -298,7 +348,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ const committedMarkdown = useMemo( () => committed ? ( - + {committed} ) : null, @@ -307,30 +357,34 @@ const MarkdownPreview = memo(function MarkdownPreview({ if (onCheckboxToggle) { return ( - -
- - {content} - -
-
+ + +
+ + {content} + +
+
+
) } return ( -
- {committedMarkdown} - {incoming && ( -
:first-child]:mt-0')} - > - - {incoming} - -
- )} -
+ +
+ {committedMarkdown} + {incoming && ( +
:first-child]:mt-0')} + > + + {incoming} + +
+ )} +
+
) }) From 0f95d736baeec54d737ad0d551991b5755643f9a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 3 Apr 2026 19:01:44 -0700 Subject: [PATCH 2/5] Fix lint --- .../components/file-viewer/preview-panel.tsx | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 378154d1de..1a4278d671 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1,12 +1,12 @@ 'use client' import { createContext, memo, useCallback, useContext, useMemo, useRef } from 'react' +import { useRouter } from 'next/navigation' import type { Components, ExtraProps } from 'react-markdown' import ReactMarkdown from 'react-markdown' -import remarkBreaks from 'remark-breaks' import rehypeSlug from 'rehype-slug' +import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' -import { useRouter } from 'next/navigation' import { Checkbox } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -95,22 +95,34 @@ const STATIC_MARKDOWN_COMPONENTS = {

), h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( -

+

{children}

), h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( -

+

{children}

), h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( -

+

{children}

), h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => ( -

+

{children}

), @@ -348,7 +360,11 @@ const MarkdownPreview = memo(function MarkdownPreview({ const committedMarkdown = useMemo( () => committed ? ( - + {committed} ) : null, @@ -360,7 +376,11 @@ const MarkdownPreview = memo(function MarkdownPreview({
- + {content}
@@ -378,7 +398,11 @@ const MarkdownPreview = memo(function MarkdownPreview({ key={generation} className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')} > - + {incoming} From 4bb4b1b338f3402cd5b3a673fb661ba97f76a79e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Apr 2026 11:27:14 -0700 Subject: [PATCH 3/5] Reference correct scroll container --- .../components/file-viewer/preview-panel.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 1a4278d671..3ed3cfd0c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -274,11 +274,14 @@ function InputRenderer({ ) } -function isInternalHref(href: string): { pathname: string; hash: string } | null { +function isInternalHref( + href: string, + origin = window.location.origin +): { pathname: string; hash: string } | null { if (href.startsWith('#')) return { pathname: '', hash: href } try { - const url = new URL(href, window.location.origin) - if (url.origin === window.location.origin) { + const url = new URL(href, origin) + if (url.origin === origin) { return { pathname: url.pathname, hash: url.hash } } } catch { @@ -303,12 +306,22 @@ function AnchorRenderer({ href, children }: { href?: string; children?: React.Re if (parsed.pathname === '' && parsed.hash) { const el = document.getElementById(parsed.hash.slice(1)) - el?.scrollIntoView({ behavior: 'smooth' }) + if (el) { + const container = el.closest('.overflow-auto') as HTMLElement | null + if (container) { + container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' }) + } else { + el.scrollIntoView({ behavior: 'smooth' }) + } + } return } + const destination = parsed.pathname + parsed.hash if (navigate) { - navigate(parsed.pathname + parsed.hash) + navigate(destination) + } else { + window.location.assign(destination) } }, [parsed, navigate] From ec14b30fe72ecfa1ff7ef4e9d1d7fd50d3d755f5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Apr 2026 13:26:17 -0700 Subject: [PATCH 4/5] Add resource tab to url state, scroll correctly on new tab --- .../components/file-viewer/preview-panel.tsx | 22 ++++++++++++++++--- .../app/workspace/[workspaceId]/home/home.tsx | 16 ++++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 11 ++++++++-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 3ed3cfd0c2..953ceac903 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { createContext, memo, useCallback, useContext, useMemo, useRef } from 'react' +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react' import { useRouter } from 'next/navigation' import type { Components, ExtraProps } from 'react-markdown' import ReactMarkdown from 'react-markdown' @@ -281,11 +281,11 @@ function isInternalHref( if (href.startsWith('#')) return { pathname: '', hash: href } try { const url = new URL(href, origin) - if (url.origin === origin) { + if (url.origin === origin && url.pathname.startsWith('/workspace/')) { return { pathname: url.pathname, hash: url.hash } } } catch { - if (href.startsWith('/')) { + if (href.startsWith('/workspace/')) { const hashIdx = href.indexOf('#') if (hashIdx === -1) return { pathname: href, hash: '' } return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) } @@ -370,6 +370,22 @@ const MarkdownPreview = memo(function MarkdownPreview({ [onCheckboxToggle] ) + const hasScrolledToHash = useRef(false) + useEffect(() => { + const hash = window.location.hash + if (!hash || hasScrolledToHash.current) return + const id = hash.slice(1) + const el = document.getElementById(id) + if (!el) return + hasScrolledToHash.current = true + const container = el.closest('.overflow-auto') as HTMLElement | null + if (container) { + container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' }) + } else { + el.scrollIntoView({ behavior: 'smooth' }) + } + }, [content]) + const committedMarkdown = useMemo( () => committed ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 5e98fc6e51..3b83de43c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams, useRouter } from 'next/navigation' +import { useParams, useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { PanelLeft } from '@/components/emcn/icons' import { useSession } from '@/lib/auth/auth-client' @@ -28,6 +28,8 @@ interface HomeProps { export function Home({ chatId }: HomeProps = {}) { const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() + const searchParams = useSearchParams() + const initialResourceId = searchParams.get('resource') const { data: session } = useSession() const posthog = usePostHog() const posthogRef = useRef(posthog) @@ -160,7 +162,10 @@ export function Home({ chatId }: HomeProps = {}) { } = useChat( workspaceId, chatId, - getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent }) + getMothershipUseChatOptions({ + onResourceEvent: handleResourceEvent, + initialActiveResourceId: initialResourceId, + }) ) const [editingInputValue, setEditingInputValue] = useState('') @@ -183,6 +188,13 @@ export function Home({ chatId }: HomeProps = {}) { [editQueuedMessage] ) + useEffect(() => { + if (!activeResourceId) return + const url = new URL(window.location.href) + url.searchParams.set('resource', activeResourceId) + window.history.replaceState(null, '', url.toString()) + }, [activeResourceId]) + useEffect(() => { wasSendingRef.current = false if (resolvedChatId) markRead(resolvedChatId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5fa965d9ab..860689d904 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -377,10 +377,11 @@ export interface UseChatOptions { onToolResult?: (toolName: string, success: boolean, result: unknown) => void onTitleUpdate?: () => void onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void + initialActiveResourceId?: string | null } export function getMothershipUseChatOptions( - options: Pick = {} + options: Pick = {} ): UseChatOptions { return { apiPath: MOTHERSHIP_CHAT_API_PATH, @@ -416,6 +417,7 @@ export function useChat( const [resolvedChatId, setResolvedChatId] = useState(initialChatId) const [resources, setResources] = useState([]) const [activeResourceId, setActiveResourceId] = useState(null) + const initialActiveResourceIdRef = useRef(options?.initialActiveResourceId) const onResourceEventRef = useRef(options?.onResourceEvent) onResourceEventRef.current = options?.onResourceEvent const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH) @@ -845,7 +847,12 @@ export function useChat( const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file') if (persistedResources.length > 0) { setResources(persistedResources) - setActiveResourceId(persistedResources[persistedResources.length - 1].id) + const initialId = initialActiveResourceIdRef.current + const restoredId = + initialId && persistedResources.some((r) => r.id === initialId) + ? initialId + : persistedResources[persistedResources.length - 1].id + setActiveResourceId(restoredId) for (const resource of persistedResources) { if (resource.type !== 'workflow') continue From 865d7d7b51a8742f8a49db0ee005623dbef69b93 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Apr 2026 13:52:03 -0700 Subject: [PATCH 5/5] Handle delete all resource by clearing url --- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 3b83de43c9..f477e0e1fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -189,9 +189,12 @@ export function Home({ chatId }: HomeProps = {}) { ) useEffect(() => { - if (!activeResourceId) return const url = new URL(window.location.href) - url.searchParams.set('resource', activeResourceId) + if (activeResourceId) { + url.searchParams.set('resource', activeResourceId) + } else { + url.searchParams.delete('resource') + } window.history.replaceState(null, '', url.toString()) }, [activeResourceId])