Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 88 additions & 6 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface ResolvedMarkdownSnapshot {
themeKey: string
highlightEnabled: boolean
escapeRawHtml: boolean
defaultCodeBlockWrap: boolean
partId: string | undefined
cacheId: string
version: string
Expand Down Expand Up @@ -96,6 +97,7 @@ interface MarkdownProps {
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
escapeRawHtml?: boolean
defaultCodeBlockWrap?: boolean
onRendered?: () => void
}

Expand All @@ -105,32 +107,87 @@ export function Markdown(props: MarkdownProps) {
let containerRef: HTMLDivElement | undefined
let latestRequestKey = ""
let cleanupLanguageListener: (() => void) | undefined
const codeBlockWrapOverrides = new Map<string, boolean>()

const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.())
}

const codeBlockWrapKey = (codeBlock: HTMLElement): string | null => {
const key = codeBlock.getAttribute("data-code-block-key")
if (!key) {
return null
}
return `${resolved().cacheId}:${key}`
}

const applyCodeBlockWrapState = (codeBlock: HTMLElement, enabled: boolean) => {
codeBlock.setAttribute("data-wrap-lines", enabled ? "true" : "false")

const button = codeBlock.querySelector<HTMLButtonElement>(".code-block-wrap")
if (!button) {
return
}

const label = enabled ? t("markdown.codeBlock.wrap.disable") : t("markdown.codeBlock.wrap.enable")
button.classList.toggle("active", enabled)
button.setAttribute("aria-pressed", enabled ? "true" : "false")
button.setAttribute("aria-label", label)
button.setAttribute("title", label)

const text = button.querySelector(".wrap-text")
if (text) {
text.textContent = label
}
}

const syncCodeBlockWrapStates = () => {
if (!containerRef) {
return
}

const codeBlocks = containerRef.querySelectorAll<HTMLElement>(".markdown-code-block")
for (const codeBlock of codeBlocks) {
const key = codeBlockWrapKey(codeBlock)
const defaultEnabled = codeBlock.getAttribute("data-wrap-lines") !== "false"
const enabled = key ? (codeBlockWrapOverrides.get(key) ?? defaultEnabled) : defaultEnabled
applyCodeBlockWrapState(codeBlock, enabled)
}
}

const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const defaultCodeBlockWrap = props.defaultCodeBlockWrap ?? true
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}:${version}`
return {
part,
text,
themeKey,
highlightEnabled,
escapeRawHtml,
defaultCodeBlockWrap,
partId,
cacheId,
version,
requestKey,
}
})

const cacheHandle = useGlobalCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: "markdown",
cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
const { cacheId, themeKey, highlightEnabled, escapeRawHtml, defaultCodeBlockWrap } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}`
},
version: () => resolved().version,
})
Expand All @@ -144,7 +201,7 @@ export function Markdown(props: MarkdownProps) {
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}`,
}
setHtml(renderedHtml)
if (options?.cache ?? true) {
Expand All @@ -159,6 +216,7 @@ export function Markdown(props: MarkdownProps) {
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
defaultCodeBlockWrap: snapshot.defaultCodeBlockWrap,
})
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)

Expand All @@ -170,7 +228,7 @@ export function Markdown(props: MarkdownProps) {
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}`

const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
Expand Down Expand Up @@ -202,9 +260,33 @@ export function Markdown(props: MarkdownProps) {
})
})

createEffect(() => {
html()
Promise.resolve().then(syncCodeBlockWrapStates)
})

onMount(() => {
const handleClick = async (event: Event) => {
const target = event.target as HTMLElement
const wrapButton = target.closest(".code-block-wrap") as HTMLButtonElement
if (wrapButton) {
event.preventDefault()
const codeBlock = wrapButton.closest(".markdown-code-block") as HTMLElement | null
if (!codeBlock) {
return
}

const key = codeBlockWrapKey(codeBlock)
const current = codeBlock.getAttribute("data-wrap-lines") !== "false"
const next = !current
if (key) {
codeBlockWrapOverrides.set(key, next)
}
applyCodeBlockWrapState(codeBlock, next)
props.onRendered?.()
return
}

const copyButton = target.closest(".code-block-copy") as HTMLButtonElement

if (!copyButton) {
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/components/tool-call/markdown-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export function createMarkdownContentRenderer(params: {

const size = options.size || "default"
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${options.wrap ? " tool-call-markdown-wrap" : ""}`
const wrapEnabled = options.wrap ?? true
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${wrapEnabled ? " tool-call-markdown-wrap" : ""}`
const state = params.toolState()
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
Expand Down Expand Up @@ -73,6 +74,7 @@ export function createMarkdownContentRenderer(params: {
sessionId={params.sessionId}
isDark={params.isDark()}
disableHighlight={disableHighlight}
defaultCodeBlockWrap={wrapEnabled}
onRendered={handleMarkdownRendered}
/>
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/de/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "Kopieren",
"markdown.codeBlock.copy.copied": "Kopiert!",
"markdown.codeBlock.copy.failed": "Fehlgeschlagen",
"markdown.codeBlock.wrap.enable": "Zeilenumbruch aktivieren",
"markdown.codeBlock.wrap.disable": "Zeilenumbruch deaktivieren",

"markdown.copy": "Kopieren",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/en/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "Copy",
"markdown.codeBlock.copy.copied": "Copied!",
"markdown.codeBlock.copy.failed": "Failed",
"markdown.codeBlock.wrap.enable": "Enable word wrap",
"markdown.codeBlock.wrap.disable": "Disable word wrap",

"markdown.copy": "Copy",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/es/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "Copiar",
"markdown.codeBlock.copy.copied": "¡Copiado!",
"markdown.codeBlock.copy.failed": "Error",
"markdown.codeBlock.wrap.enable": "Activar ajuste de línea",
"markdown.codeBlock.wrap.disable": "Desactivar ajuste de línea",

"markdown.copy": "Copiar",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/fr/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "Copier",
"markdown.codeBlock.copy.copied": "Copié !",
"markdown.codeBlock.copy.failed": "Échec",
"markdown.codeBlock.wrap.enable": "Activer le retour à la ligne",
"markdown.codeBlock.wrap.disable": "Désactiver le retour à la ligne",

"markdown.copy": "Copier",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/he/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "העתק",
"markdown.codeBlock.copy.copied": "הועתק!",
"markdown.codeBlock.copy.failed": "נכשל",
"markdown.codeBlock.wrap.enable": "הפעל גלישת שורות",
"markdown.codeBlock.wrap.disable": "כבה גלישת שורות",

"markdown.copy": "העתק",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/ja/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "コピー",
"markdown.codeBlock.copy.copied": "コピーしました!",
"markdown.codeBlock.copy.failed": "失敗",
"markdown.codeBlock.wrap.enable": "折り返しを有効にする",
"markdown.codeBlock.wrap.disable": "折り返しを無効にする",

"markdown.copy": "コピー",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/ne/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "प्रतिलिपि गर्नुहोस्",
"markdown.codeBlock.copy.copied": "प्रतिलिपि गरियो!",
"markdown.codeBlock.copy.failed": "असफल",
"markdown.codeBlock.wrap.enable": "लाइन र्‍याप सक्षम गर्नुहोस्",
"markdown.codeBlock.wrap.disable": "लाइन र्‍याप असक्षम गर्नुहोस्",

"markdown.copy": "प्रतिलिपि गर्नुहोस्",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/ru/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "Копировать",
"markdown.codeBlock.copy.copied": "Скопировано!",
"markdown.codeBlock.copy.failed": "Не удалось",
"markdown.codeBlock.wrap.enable": "Включить перенос строк",
"markdown.codeBlock.wrap.disable": "Отключить перенос строк",

"markdown.copy": "Копировать",
} as const
2 changes: 2 additions & 0 deletions packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const markdownMessages = {
"markdown.codeBlock.copy.label": "复制",
"markdown.codeBlock.copy.copied": "已复制!",
"markdown.codeBlock.copy.failed": "失败",
"markdown.codeBlock.wrap.enable": "启用自动换行",
"markdown.codeBlock.wrap.disable": "禁用自动换行",

"markdown.copy": "复制",
} as const
Loading
Loading