diff --git a/tools/server/webui/src/app.css b/tools/server/webui/src/app.css index 311ffc033d..48107049ab 100644 --- a/tools/server/webui/src/app.css +++ b/tools/server/webui/src/app.css @@ -130,6 +130,40 @@ scrollbar-width: thin; scrollbar-gutter: stable; } + + /* Global scrollbar styling - visible only on hover */ + * { + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.2s ease; + } + + *:hover { + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; + } + + *::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 3px; + transition: background 0.2s ease; + } + + *:hover::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + } + + *::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); + } } @layer utilities { diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte index ea7993c9d9..f58dc37486 100644 --- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -21,7 +21,9 @@ import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; import githubLightCss from 'highlight.js/styles/github.css?inline'; import { mode } from 'mode-watcher'; + import hljs from 'highlight.js'; import CodePreviewDialog from './CodePreviewDialog.svelte'; + import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import type { DatabaseMessage } from '$lib/types/database'; interface Props { @@ -36,14 +38,25 @@ html: string; } + interface IncompleteCodeBlock { + language: string; + code: string; + openingIndex: number; + } + let { content, message, class: className = '', disableMath = false }: Props = $props(); let containerRef = $state(); let renderedBlocks = $state([]); let unstableBlockHtml = $state(''); + let incompleteCodeBlock = $state(null); let previewDialogOpen = $state(false); let previewCode = $state(''); let previewLanguage = $state('text'); + let streamingCodeScrollContainer = $state(); + + // Auto-scroll controller for streaming code block content + const streamingAutoScroll = createAutoScrollController(); let pendingMarkdown: string | null = null; let isProcessing = false; @@ -212,6 +225,72 @@ } } + /** + * Highlights code using highlight.js + * @param code - The code to highlight + * @param language - The programming language + * @returns HTML string with syntax highlighting + */ + function highlightCode(code: string, language: string): string { + if (!code) return ''; + + try { + const lang = language.toLowerCase(); + const isSupported = hljs.getLanguage(lang); + + if (isSupported) { + return hljs.highlight(code, { language: lang }).value; + } else { + return hljs.highlightAuto(code).value; + } + } catch { + // Fallback to escaped plain text + return code.replace(/&/g, '&').replace(//g, '>'); + } + } + + /** + * Detects if markdown ends with an incomplete code block (opened but not closed). + * Returns the code block info if found, null otherwise. + * @param markdown - The raw markdown string to check + * @returns IncompleteCodeBlock info or null + */ + function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null { + // Count all code fences in the markdown + // A code block is incomplete if there's an odd number of ``` fences + const fencePattern = /^```|\n```/g; + const fences: number[] = []; + let fenceMatch; + + while ((fenceMatch = fencePattern.exec(markdown)) !== null) { + // Store the position after the ``` + const pos = fenceMatch[0].startsWith('\n') ? fenceMatch.index + 1 : fenceMatch.index; + fences.push(pos); + } + + // If even number of fences (including 0), all code blocks are closed + if (fences.length % 2 === 0) { + return null; + } + + // Odd number means last code block is incomplete + // The last fence is the opening of the incomplete block + const openingIndex = fences[fences.length - 1]; + const afterOpening = markdown.slice(openingIndex + 3); + + // Extract language and code content + const langMatch = afterOpening.match(/^(\w*)\n?/); + const language = langMatch?.[1] || 'text'; + const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0); + const code = markdown.slice(codeStartIndex); + + return { + language, + code, + openingIndex + }; + } + /** * Handles click events on preview buttons within HTML code blocks. * Opens a preview dialog with the rendered HTML content. @@ -241,15 +320,60 @@ /** * Processes markdown content into stable and unstable HTML blocks. * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered. + * Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity. * @param markdown - The raw markdown string to process */ async function processMarkdown(markdown: string) { if (!markdown) { renderedBlocks = []; unstableBlockHtml = ''; + incompleteCodeBlock = null; return; } + // Check for incomplete code block at the end of content + const incompleteBlock = detectIncompleteCodeBlock(markdown); + + if (incompleteBlock) { + // Process only the prefix (content before the incomplete code block) + const prefixMarkdown = markdown.slice(0, incompleteBlock.openingIndex); + + if (prefixMarkdown.trim()) { + const normalizedPrefix = preprocessLaTeX(prefixMarkdown); + const processorInstance = processor(); + const ast = processorInstance.parse(normalizedPrefix) as MdastRoot; + const processedRoot = (await processorInstance.run(ast)) as HastRoot; + const processedChildren = processedRoot.children ?? []; + const nextBlocks: MarkdownBlock[] = []; + + // All prefix blocks are now stable since code block is separate + for (let index = 0; index < processedChildren.length; index++) { + const hastChild = processedChildren[index]; + const id = getHastNodeId(hastChild, index); + const existing = renderedBlocks[index]; + + if (existing && existing.id === id) { + nextBlocks.push(existing); + continue; + } + + const html = stringifyProcessedNode(processorInstance, processedRoot, hastChild); + nextBlocks.push({ id, html }); + } + + renderedBlocks = nextBlocks; + } else { + renderedBlocks = []; + } + + unstableBlockHtml = ''; + incompleteCodeBlock = incompleteBlock; + return; + } + + // No incomplete code block - use standard processing + incompleteCodeBlock = null; + const normalized = preprocessLaTeX(markdown); const processorInstance = processor(); const ast = processorInstance.parse(normalized) as MdastRoot; @@ -423,9 +547,19 @@ } }); + // Auto-scroll for streaming code block + $effect(() => { + streamingAutoScroll.setContainer(streamingCodeScrollContainer); + }); + + $effect(() => { + streamingAutoScroll.updateInterval(incompleteCodeBlock !== null); + }); + onDestroy(() => { cleanupEventListeners(); cleanupHighlightTheme(); + streamingAutoScroll.destroy(); }); @@ -443,6 +577,79 @@ {@html unstableBlockHtml} {/if} + + {#if incompleteCodeBlock} +
+
+ {incompleteCodeBlock.language || 'text'} +
+ + {#if incompleteCodeBlock.language?.toLowerCase() === 'html'} + + {/if} +
+
+
streamingAutoScroll.handleScroll()} + > +
{@html highlightCode(
+							incompleteCodeBlock.code,
+							incompleteCodeBlock.language || 'text'
+						)}
+
+
+ {/if}