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])