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