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..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,8 +1,10 @@
'use client'
-import { createContext, memo, 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'
+import rehypeSlug from 'rehype-slug'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { Checkbox } from '@/components/emcn'
@@ -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,43 @@ 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 +155,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 +274,75 @@ function InputRenderer({
)
}
+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, origin)
+ if (url.origin === origin && url.pathname.startsWith('/workspace/')) {
+ return { pathname: url.pathname, hash: url.hash }
+ }
+ } catch {
+ 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) }
+ }
+ }
+ 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))
+ 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(destination)
+ } else {
+ window.location.assign(destination)
+ }
+ },
+ [parsed, navigate]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
const MARKDOWN_COMPONENTS = {
...STATIC_MARKDOWN_COMPONENTS,
+ a: AnchorRenderer,
ul: UlRenderer,
ol: OlRenderer,
li: LiRenderer,
@@ -284,6 +358,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)
@@ -295,10 +370,30 @@ 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 ? (
-
+
{committed}
) : null,
@@ -307,30 +402,42 @@ 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}
+
+
+ )}
+
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index 5e98fc6e51..f477e0e1fd 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,16 @@ export function Home({ chatId }: HomeProps = {}) {
[editQueuedMessage]
)
+ useEffect(() => {
+ const url = new URL(window.location.href)
+ if (activeResourceId) {
+ url.searchParams.set('resource', activeResourceId)
+ } else {
+ url.searchParams.delete('resource')
+ }
+ 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