import '@assistant-ui/react-markdown/styles/dot.css' import type { ComponentPropsWithoutRef, ComponentType, MouseEvent, ReactNode } from 'react' import { useState, useCallback, useEffect, useMemo, createContext, useContext } from 'react' import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, useIsMarkdownCodeBlock, type CodeHeaderProps, } from '@assistant-ui/react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code' import remarkRepairTables from '@/lib/remark-repair-tables' import { useNavigate } from '@tanstack/react-router' import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink' import remarkNonHttpsAutolink from '@/lib/remark-non-https-autolink' import { cn, encodeBase64 } from '@/lib/utils' import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { useCodeWrap } from '@/hooks/useCodeWrap' import { CopyIcon, CheckIcon, WrapIcon } from '@/components/icons' import { useTranslation } from '@/lib/use-translation' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links' import { remarkSessionPathLinks } from '@/lib/remark-session-path-links' import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference' import { UriConfirmDialog } from '@/components/UriConfirmDialog' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' // ── Plugin array ──────────────────────────────────────────────────────────── // Order: remarkGfm → remarkRepairTables → remarkNonHttpsAutolink → remarkStripCjkAutolink → remarkMath → remarkDisableIndentedCode → remarkSessionPathLinks → remarkFilePathLinks // remarkRepairTables must run immediately after remarkGfm — it reads file.value // (raw source) to pad short separator rows before remark-gfm parses the table. // remarkNonHttpsAutolink must run BEFORE remarkStripCjkAutolink so that the // CJK strip plugin sees the new link nodes and can trim trailing CJK punctuation // from them. Both must come before remarkMath (to avoid treating TeX as URI). // remarkSessionPathLinks turns bare /sessions/ citations into links. // remarkFilePathLinks runs last to convert file paths → links after all other // transforms have settled. // // remarkMath is configured with `singleDollarTextMath: false` so that prose // containing currency amounts (e.g. "$200/mo ... $80 bill") is not garbled // into KaTeX output. Block math `$$...$$` (on its own line) still works. // This matches GitHub-flavored markdown behavior. The option lives on the // shared TAIL so both MARKDOWN_PLUGINS (default) and MARKDOWN_PLUGINS_WITH_BREAKS // (user-prompt rendering with hard breaks) inherit the fix. const MARKDOWN_PLUGIN_TAIL_HEAD = [ remarkNonHttpsAutolink, remarkStripCjkAutolink, [remarkMath, { singleDollarTextMath: false }], remarkDisableIndentedCode, ] satisfies NonNullable const MARKDOWN_PLUGIN_TAIL = [ ...MARKDOWN_PLUGIN_TAIL_HEAD, remarkSessionPathLinks, // bare /sessions/ → clickable session citation remarkFilePathLinks, // upstream — file path → link conversion, runs last ] satisfies NonNullable // Standalone surfaces (file-preview) render without HappyChatContext, so the // FilePathAnchor cannot route hapi-file: hrefs — rewriting explicit markdown // links there would collapse them to plain text. Keep bare-path / inlineCode // autolinks (already inert on that surface) but disable explicit-link rewrite. const MARKDOWN_PLUGIN_TAIL_STANDALONE = [ ...MARKDOWN_PLUGIN_TAIL_HEAD, remarkSessionPathLinks, [remarkFilePathLinks, { rewriteExplicitLinks: false }], ] satisfies NonNullable export const MARKDOWN_PLUGINS = [ remarkGfm, remarkRepairTables, ...MARKDOWN_PLUGIN_TAIL, ] satisfies NonNullable export const MARKDOWN_PLUGINS_STANDALONE = [ remarkGfm, remarkRepairTables, ...MARKDOWN_PLUGIN_TAIL_STANDALONE, ] satisfies NonNullable // User-authored prompts should preserve Shift+Enter/newline intent without // changing assistant/tool markdown behavior globally. export const MARKDOWN_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkRepairTables, remarkBreaks, ...MARKDOWN_PLUGIN_TAIL, ] satisfies NonNullable export const MARKDOWN_PLUGINS_STANDALONE_WITH_BREAKS = [ remarkGfm, remarkRepairTables, remarkBreaks, ...MARKDOWN_PLUGIN_TAIL_STANDALONE, ] satisfies NonNullable export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]' export const MARKDOWN_COMPONENTS_BY_LANGUAGE = { mermaid: { SyntaxHighlighter: MermaidDiagram, }, } satisfies NonNullable // ── URI scheme policy (inlined from url-scheme-policy.ts) ─────────────────── // // IANA-registered safe schemes — mirrors react-markdown defaultUrlTransform exactly. // Source: node_modules/react-markdown/lib/index.js:124 /^(https?|ircs?|mailto|xmpp)$/i const IANA_SAFE_SCHEMES: ReadonlySet = new Set([ 'http', 'https', 'irc', 'ircs', 'mailto', 'xmpp', ]) // Schemes that must always be blocked regardless of user preference. // Includes file: because Chromium denies navigation from http(s) origins. const DENY_SCHEMES: ReadonlySet = new Set([ 'javascript', 'data', 'vbscript', 'file', ]) /** * Extract the normalised scheme from a URL string. * * Applies up to two rounds of decodeURIComponent so that double-encoded * bypass attempts (`javascript%253A` → `javascript%3A` → `javascript:`) * are unwrapped before the scheme is extracted. * * After decoding, ASCII control characters (U+0000–U+001F, U+007F) and * all whitespace are stripped from the extracted scheme string so that * browsers' built-in normalization — which silently discards \t, \n, \r * and space from scheme names during navigation — cannot be used to bypass * the deny list (e.g. `java\nscript:alert(1)` → scheme `"javascript"`). * * Returns null when no valid scheme separator is found. */ function normalizedScheme(url: string): string | null { let value = url.trimStart() for (let i = 0; i < 2; i++) { try { const next = decodeURIComponent(value) if (next === value) break value = next } catch { break } } const colonIndex = value.indexOf(':') if (colonIndex <= 0) return null // Strip ASCII control chars (U+0000–U+001F, DEL U+007F) and all whitespace // from the scheme name. Browsers strip exactly these characters during // navigation, so `java\nscript` navigates as `javascript`. return value.slice(0, colonIndex).replace(/[\x00-\x1F\x7F\s]/g, '').toLowerCase() } /** * Classify a URL string by its scheme. * * Uses normalizedScheme() so that common bypass patterns — control characters * spliced into the scheme name, tab/space prefix, single- and double-encoded * scheme characters — are all caught before the deny/IANA comparison. * * @returns 'iana' | 'deny' | 'custom' */ export function classifyScheme(url: string): 'iana' | 'deny' | 'custom' { const scheme = normalizedScheme(url) if (scheme === null) return 'deny' if (DENY_SCHEMES.has(scheme)) return 'deny' if (IANA_SAFE_SCHEMES.has(scheme)) return 'iana' return 'custom' } /** * Returns true when href contains a URL scheme (i.e. a "scheme:" prefix that * appears before any path/query/fragment delimiter). * * This distinguishes `mailto:foo@bar` (has scheme → true) from purely relative * hrefs like `/settings`, `./foo`, `#section`, `?q=1`, or paths that contain a * colon after a path segment like `/path:colon` (→ false, because `/` appears * before `:`). Protocol-relative URLs (`//host/path`) have no colon and are * also treated as scheme-less; browsers navigate them as the current origin's * protocol, same as a normal relative path would do. * * Used by to short-circuit classifyScheme for no-scheme hrefs and treat them * as 'iana' so the browser/router can handle them normally (fixing the regression * where relative markdown links were silently blocked by the onClick deny guard). */ function hasScheme(href: string): boolean { const colonIdx = href.indexOf(':') if (colonIdx <= 0) return false const boundaryIdx = href.search(/[/?#]/) return boundaryIdx < 0 || colonIdx < boundaryIdx } // ── URL sanitize transform (deny-only) ────────────────────────────────────── // Passed as urlTransform to MarkdownTextPrimitive. Only deny-listed schemes // are stripped; every other scheme (IANA + custom) passes through so the // is preserved for the onClick layer to handle. // // Uses classifyScheme as the single source of truth for scheme extraction so // that percent-encoded bypass patterns (jav%61script:, %6Aavascript:) are // caught by the same decoding logic used at click time. // // Relative paths (no colon, or colon only in path/query) have no scheme and // are always passed through — they are safe and used for img src etc. // // Known limitation (FIX 5, deferred): data:image/png;base64,... used in // is also stripped because DENY_SCHEMES includes 'data'. However, // react-markdown's own defaultUrlTransform strips all data: URLs identically, // so this is not a regression introduced by this PR. export function denyOnlyTransform(url: string): string { if (!url) return url const trimmed = url.trimStart() const colonIdx = trimmed.indexOf(':') const slashIdx = trimmed.search(/[/?#]/) if (colonIdx < 0 || (slashIdx >= 0 && slashIdx < colonIdx)) { return url } return classifyScheme(url) === 'deny' ? '' : url } // ── Allowed-schemes state (inlined from useAllowedSchemes.ts) ─────────────── const STORAGE_KEY = 'hapi-allowed-schemes' // Module-level subscriber set for intra-tab cross-provider sync (P7e.1). // // `window` `storage` events fire only in OTHER tabs/windows, not in the same // tab that called setItem. When two s exist in the same // window (e.g. MarkdownText + Reasoning as sibling providers in AssistantMessage), // clicking "Always allow" in one provider's dialog must immediately update the // other without waiting for a page reload. // // Solution: a module-level Set of subscriber callbacks. writeAllowedToStorage // calls each subscriber synchronously after writing to localStorage. Each // useAllowedSchemes instance registers on mount and unregisters on unmount. // Cross-tab sync continues to use the existing window `storage` event. type SchemeListener = (schemes: ReadonlySet) => void const schemeListeners = new Set() function readAllowedFromStorage(): Set { try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return new Set() const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return new Set() return new Set( parsed .filter((s): s is string => typeof s === 'string') .filter((s) => !DENY_SCHEMES.has(s)) ) } catch { return new Set() } } function writeAllowedToStorage(schemes: ReadonlySet): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(schemes))) } catch { // Storage quota exceeded — silently ignore; in-memory set still works. } // Notify all same-tab subscribers synchronously so sibling providers // (e.g. MarkdownText + Reasoning) update in the same React event loop tick. for (const listener of schemeListeners) { listener(schemes) } } function useAllowedSchemes() { const [allowed, setAllowed] = useState>(() => readAllowedFromStorage()) useEffect(() => { // Cross-tab sync: storage event fires in other tabs/windows. function handleStorage(e: StorageEvent) { if (e.key !== STORAGE_KEY) return setAllowed(readAllowedFromStorage()) } window.addEventListener('storage', handleStorage) // Intra-tab sync: subscribe to module-level emitter so sibling // providers in the same window see updates without page reload. function handleIntraTab(schemes: ReadonlySet) { setAllowed(new Set(schemes)) } schemeListeners.add(handleIntraTab) return () => { window.removeEventListener('storage', handleStorage) schemeListeners.delete(handleIntraTab) } }, []) const allow = useCallback((scheme: string) => { if (DENY_SCHEMES.has(scheme)) return setAllowed((prev) => { if (prev.has(scheme)) return prev const next = new Set(prev) next.add(scheme) writeAllowedToStorage(next) return next }) }, []) const isAllowed = useCallback( (scheme: string): boolean => { if (DENY_SCHEMES.has(scheme)) return false return allowed.has(scheme) }, [allowed] ) return { allowed, allow, isAllowed } } // ── UriConfirmContext — one dialog per markdown root ──────────────────────── type DialogState = { url: string scheme: string } | null type UriConfirmContextValue = { openUri: (url: string, scheme: string) => void /** Shared isAllowed so all tags in this tree re-render on the same state update. */ isAllowed: (scheme: string) => boolean } const UriConfirmContext = createContext(null) /** * Provider that mounts a single for all links in its * subtree. Wrap each MarkdownText / MarkdownRenderer / Reasoning surface with * this so that only one dialog instance exists per markdown root instead of * one per tag. * * The allowed-schemes state lives here so all child components share the * same React state — when "Always allow" is clicked, every link in the tree * re-renders in the same React commit (no cross-tab storage event required). */ export function UriConfirmProvider({ children }: { children: ReactNode }) { const [dialog, setDialog] = useState(null) const { allow, isAllowed } = useAllowedSchemes() const openUri = useCallback((url: string, scheme: string) => { setDialog({ url, scheme }) }, []) const closeDialog = () => setDialog(null) const handleOpen = () => { if (!dialog) return closeDialog() window.open(dialog.url, '_blank', 'noopener,noreferrer') } const handleAlwaysAllow = (allowedScheme: string) => { allow(allowedScheme) closeDialog() if (dialog) { window.open(dialog.url, '_blank', 'noopener,noreferrer') } } const contextValue = useMemo(() => ({ openUri, isAllowed }), [openUri, isAllowed]) return ( {children} {dialog !== null && ( )} ) } // ── Components ─────────────────────────────────────────────────────────────── function CodeHeader(props: CodeHeaderProps) { const { t } = useTranslation() const { copied, copy } = useCopyToClipboard() const { codeWrap, setCodeWrap } = useCodeWrap() const language = props.language && props.language !== 'unknown' ? props.language : 'text' return (
{language}
) } function Pre(props: ComponentPropsWithoutRef<'pre'>) { const { className, style, ...rest } = props const { codeWrap } = useCodeWrap() return (
        
) } function Code(props: ComponentPropsWithoutRef<'code'>) { const isCodeBlock = useIsMarkdownCodeBlock() if (isCodeBlock) { return ( ) } return ( ) } function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) { const navigate = useNavigate() const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString() const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}` const handleClick = (event: MouseEvent) => { props.onClick?.(event) if (event.defaultPrevented) return if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return event.preventDefault() void navigate({ to: '/sessions/$sessionId/file', params: { sessionId: props.sessionId }, search: { path: encodeBase64(props.filePath) } }) } return (
) } function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessionId: string }) { const navigate = useNavigate() const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel // Preserve Vite BASE_URL for copy / open-in-new-tab (SPA click uses navigate). const href = buildSessionReferencePath(props.targetSessionId) const handleClick = (event: MouseEvent) => { props.onClick?.(event) if (event.defaultPrevented) return if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return event.preventDefault() void navigate({ to: '/sessions/$sessionId', params: { sessionId: props.targetSessionId }, }) } return ( ) } /** * Anchor component with URI scheme policy enforcement. * * - Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1): passed through * without interception so the browser or SPA router can navigate normally. * - IANA safe schemes (https/http/mailto/irc/ircs/xmpp): navigate directly. * - Deny schemes (javascript/data/vbscript/file): silently block. denyOnlyTransform * already strips the href to "", so href="" in DOM (belt-and-suspenders onClick * guard also calls preventDefault). * - Custom schemes, NOT yet allowed by user: href="#" in DOM (not the live URL) * so middle-click / drag-to-bar cannot bypass the dialog. Dialog opens on left-click * via the shared UriConfirmContext (single dialog per markdown root). * - Custom schemes, already allowed by user: live href in DOM; middle-click works. * - File-path links (decoded by remarkFilePathLinks): delegated to FilePathAnchor * which uses useNavigate for SPA routing. * - Session citation paths (`/sessions/`): SessionPathAnchor SPA navigation. */ function A(props: ComponentPropsWithoutRef<'a'>) { const chat = useOptionalHappyChatContext() // useContext must be called unconditionally before any early return so that // the Rules of Hooks are satisfied regardless of whether `filePath` is set. // isAllowed comes exclusively from the shared context. Every call site // (MarkdownText, Reasoning, MarkdownRenderer) wraps its surface with // , so ctx is always present in production. // // The previous localHook fallback instantiated useAllowedSchemes() even // when ctx was present — 20 anchors → 21 storage listeners per mount. // Removing it requires tests that render directly to wrap with // (or supply a mock UriConfirmContext.Provider). const ctx = useContext(UriConfirmContext) const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null const targetSessionId = typeof props.href === 'string' ? parseSessionPathHref(props.href) : null const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel if (filePath) { if (!chat) { return <>{props.children} } return } if (targetSessionId) { return } const isAllowed = ctx?.isAllowed ?? (() => false) const { onClick, href, ...rest } = props // Relative / no-scheme hrefs (/settings, ./foo, #section, ?q=1) must not be // classified via classifyScheme — it returns 'deny' for inputs with no valid // scheme, which previously caused the onClick handler to preventDefault and // silently break all relative markdown links. Treat them as 'iana' so the // browser or SPA router can navigate normally. const isRelative = href ? !hasScheme(href) : false const classification = href && !isRelative ? classifyScheme(href) : 'iana' const colonIdx = href ? href.indexOf(':') : -1 const scheme = colonIdx > 0 && !isRelative ? href!.slice(0, colonIdx).toLowerCase() : '' const isCustomAllowed = classification === 'custom' && isAllowed(scheme) const domHref = classification === 'iana' || isCustomAllowed ? href : classification === 'custom' ? '#' : href const handleClick = (e: React.MouseEvent) => { const url = href ?? '' if (!url) { e.preventDefault() return } if (classification === 'deny') { e.preventDefault() return } if (classification === 'iana') { onClick?.(e) return } if (isCustomAllowed) { onClick?.(e) return } // Unallowed custom scheme: show confirmation dialog via context. e.preventDefault() ctx?.openUri(url, scheme) } return ( ) } function Paragraph(props: ComponentPropsWithoutRef<'p'>) { return

} function Blockquote(props: ComponentPropsWithoutRef<'blockquote'>) { return (

) } function UnorderedList(props: ComponentPropsWithoutRef<'ul'>) { return
    li]:mt-1.5', props.className)} /> } function OrderedList(props: ComponentPropsWithoutRef<'ol'>) { return
      li]:mt-1.5', props.className)} /> } function ListItem(props: ComponentPropsWithoutRef<'li'>) { return
    1. } function Hr(props: ComponentPropsWithoutRef<'hr'>) { return
      } function Table(props: ComponentPropsWithoutRef<'table'>) { const { className, ...rest } = props return (
      ) } function Thead(props: ComponentPropsWithoutRef<'thead'>) { return } function Tbody(props: ComponentPropsWithoutRef<'tbody'>) { return } function Tr(props: ComponentPropsWithoutRef<'tr'>) { return } function Th(props: ComponentPropsWithoutRef<'th'>) { return (
      ) } function Td(props: ComponentPropsWithoutRef<'td'>) { return } function H1(props: ComponentPropsWithoutRef<'h1'>) { return

      } function H2(props: ComponentPropsWithoutRef<'h2'>) { return

      } function H3(props: ComponentPropsWithoutRef<'h3'>) { return

      } function H4(props: ComponentPropsWithoutRef<'h4'>) { return

      } function H5(props: ComponentPropsWithoutRef<'h5'>) { return

      } function H6(props: ComponentPropsWithoutRef<'h6'>) { return
      } function Strong(props: ComponentPropsWithoutRef<'strong'>) { return } function Em(props: ComponentPropsWithoutRef<'em'>) { return } function Image(props: ComponentPropsWithoutRef<'img'>) { return } type DefaultComponentsMap = { [key: string]: ComponentType code: ComponentType pre: ComponentType CodeHeader: ComponentType SyntaxHighlighter: ComponentType } export const defaultComponents: DefaultComponentsMap = memoizeMarkdownComponents({ SyntaxHighlighter, CodeHeader, pre: Pre, code: Code, h1: H1, h2: H2, h3: H3, h4: H4, h5: H5, h6: H6, a: A, p: Paragraph, strong: Strong, em: Em, blockquote: Blockquote, ul: UnorderedList, ol: OrderedList, li: ListItem, hr: Hr, table: Table, thead: Thead, tbody: Tbody, tr: Tr, th: Th, td: Td, img: Image, } as const) as unknown as DefaultComponentsMap export function MarkdownText() { return ( ) }