-
- {#if !isEmpty} - { - autoScroll.enable(); - if (!autoScroll.userScrolledUp) { - autoScroll.scrollToBottom(); - } - }} - /> + {#if !isEmpty} + { + handleSendLikeScroll(); + }} + /> + {/if} + +
+ + + + + {#if page.params.id} + {/if} -
- - - - - - - - -
- chatStore.stopGeneration()} - onSystemPromptAdd={handleSystemPromptAdd} - bind:uploadedFiles +
+ {#if (isMobile.current ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id} + { + mobileScrollDownHint = false; + scroll.chatScrollContainer?.scrollTo({ + top: scroll.chatScrollContainer.scrollHeight, + behavior: 'smooth' + }); + }} /> -
+ {/if}
+ + chatStore.stopGeneration()} + onSystemPromptAdd={handleSystemPromptAdd} + bind:uploadedFiles={fileUpload.uploadedFiles} + />
{/if} - - - (showDeleteDialog = false)} -/> - - { - if (!open) { - emptyFileNames = []; - } - }} -/> - - diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte index a22c491adac6..dca24afd440f 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte @@ -1,58 +1,19 @@ -
- + diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte new file mode 100644 index 000000000000..6305a7438015 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte @@ -0,0 +1,55 @@ + + + + + (showDeleteDialog = false)} +/> + + { + if (!open) { + emptyFileNames = []; + } + }} +/> + + diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte index aa1c0536dbc3..600180742a42 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte @@ -2,6 +2,7 @@ import { afterNavigate } from '$app/navigation'; import { page } from '$app/state'; import { ChatForm } from '$lib/components/app'; + import { isMobile } from '$lib/stores/viewport.svelte'; import { onMount } from 'svelte'; import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte'; @@ -32,7 +33,30 @@ }: Props = $props(); let chatFormRef: ChatForm | undefined = $state(undefined); + let formWrapperEl: HTMLDivElement | undefined = $state(); let chatId = $derived(page.params.id as string | undefined); + + $effect(() => { + if (!formWrapperEl) return; + + const formEl = formWrapperEl.querySelector('form') as HTMLElement | null; + if (!formEl) return; + + const updateHeight = () => { + const height = Math.round(formEl.getBoundingClientRect().height); + document.documentElement.style.setProperty('--chat-form-height', `${height}px`); + }; + + updateHeight(); + + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(formEl); + + return () => { + resizeObserver.disconnect(); + document.documentElement.style.removeProperty('--chat-form-height'); + }; + }); let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading)); let message = $derived(initialMessage); let previousIsLoading = $derived(isLoading); @@ -83,12 +107,14 @@ } onMount(() => { - setTimeout(() => chatFormRef?.focus(), 10); + if (!isMobile.current) { + setTimeout(() => chatFormRef?.focus(), 100); + } }); afterNavigate((navigation) => { - if (navigation?.from != null) { - setTimeout(() => chatFormRef?.focus(), 10); + if (navigation?.from != null && !isMobile.current) { + setTimeout(() => chatFormRef?.focus(), 100); } }); @@ -108,12 +134,12 @@ }); -
+
- import { fadeInView } from '$lib/actions/fade-in-view.svelte'; import { serverStore } from '$lib/stores/server.svelte'; interface Props { @@ -11,10 +10,9 @@ {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenStreamResumeStatus.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenStreamResumeStatus.svelte new file mode 100644 index 000000000000..b3abe4c66080 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenStreamResumeStatus.svelte @@ -0,0 +1,18 @@ + + +{#if state === StreamConnectionState.RESUMING} +
+ + Reconnecting to the stream... +
+{/if} diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index 8ed3cc65ec80..cd06ec036619 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -241,13 +241,18 @@ export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormAct export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte'; /** - * **ChatFormReasoningToggle** - Thinking toggle button with effort dropdown + * Dropdown submenu for selecting reasoning effort level. * - * A toggle button with lightbulb icon that indicates thinking status. - * Shows the reasoning effort dropdown when clicked. + * Shows a "Reasoning" sub-menu item with a lightbulb icon indicating + * thinking status, and a nested list of effort levels. * Only visible when the current model supports thinking. */ -export { default as ChatFormReasoningToggle } from './ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte'; +export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte'; + +/** + * Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip. + */ +export { default as ChatFormContextGauge } from './ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte'; /** * Hidden file input element for programmatic file selection. @@ -566,6 +571,10 @@ export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessa * Handles streaming state with real-time content updates. */ export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte'; +export { default as ChatMessageAssistantModel } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte'; +export { default as ChatMessageAssistantProcessingInfo } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte'; +export { default as ChatMessageAssistantRawOutput } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte'; +export { default as ChatMessageAssistantStatistics } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte'; /** * Inline message editing form. Provides textarea for editing message content with @@ -669,24 +678,17 @@ export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOve */ export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte'; -/** - * Processing info display during generation. Shows real-time statistics: - * tokens per second, prompt/completion token counts, and elapsed time. - * Data sourced from slotsService polling during active generation. - * Only visible when `isCurrentConversationLoading` is true. - */ -export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte'; - -/** - * Scroll-to-bottom action button. Displays a floating button when the user - * has scrolled up more than half a viewport height from the bottom. - * Takes the chat container element as a prop to manage scroll state internally. - */ -export { default as ChatScreenActionScrollDown } from './ChatScreen/ChatScreenActionScrollDown.svelte'; - /** * Server error alert displayed when the server is unreachable. * Shows the error message with a retry button. * Rendered inside ChatScreen when `serverError` store has a value. */ export { default as ChatScreenServerError } from './ChatScreen/ChatScreenServerError.svelte'; + +/** + * Stream resume status indicator. Shows a small "Reconnecting to the stream..." + * banner with a spinner while `chatStore.streamConnectionState` is `resuming`, + * i.e. after a dropped connection is reattaching to the live SSE replay buffer. + * Renders nothing otherwise. Shown inside ChatScreen only on an active conversation route. + */ +export { default as ChatScreenStreamResumeStatus } from './ChatScreen/ChatScreenStreamResumeStatus.svelte'; diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte index 8bab55d19fb9..ea7fe7b95294 100644 --- a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte +++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte @@ -1,12 +1,8 @@ @@ -76,59 +45,54 @@ open = value; onToggle?.(); }} - class={className} + class={cn('group/collapsible', 'my-0!', className)} > - - -
-
- {#if IconComponent} - - {/if} - - {title} - - {#if subtitle} - {subtitle} - {/if} -
- - {#if displayedPreview && !showThoughtInProgress} -
-
- {displayedPreview} -
- {#if displayedOverflow > 0} - {displayedOverflow}+ chars - {/if} -
+ +
+ {#if iconUrl} + + {:else if IconComponent} + + {/if} + + + {#if titleSnippet} + {@render titleSnippet()} + {:else} + {title} {/if} -
- -
- - - Toggle content -
-
- - -
+ + + {#if subtitle} + {subtitle} + {/if} +
+ + + + Toggle content + + + +
+
{@render children()}
- - +
+
diff --git a/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte new file mode 100644 index 000000000000..4370aea42a2e --- /dev/null +++ b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte @@ -0,0 +1,97 @@ + + + { + open = value; + onToggle?.(); + }} + class={cn('group/collapsible', 'overflow-hidden rounded-md', className)} + style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);" +> + +
+ {#if iconUrl} + + {:else if IconComponent} + + {/if} + + + {#if titleSnippet} + {@render titleSnippet()} + {:else} + {title} + {/if} + + + {#if subtitle} + {subtitle} + {/if} +
+ + + + Toggle content +
+ + +
+ {@render children()} +
+
+
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index 9c4c49c0ce82..8ac7f94483a2 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -18,6 +18,8 @@ import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks'; import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks'; import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre'; + import { rehypeSvgPre } from './plugins/rehype/svg-pre'; + import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks'; import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images'; import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support'; import { remarkLiteralHtml } from './plugins/remark/literal-html'; @@ -38,11 +40,31 @@ DATA_ERROR_BOUND_ATTR, DATA_ERROR_HANDLED_ATTR, BOOL_TRUE_STRING, - SETTINGS_KEYS + SETTINGS_KEYS, + CODE_BLOCK_HEADER_CLASS, + MERMAID_WRAPPER_CLASS, + MERMAID_BLOCK_CLASS, + MERMAID_LANGUAGE, + MERMAID_SYNTAX_ATTR, + MERMAID_RENDERED_ATTR, + SVG_WRAPPER_CLASS, + SVG_BLOCK_CLASS, + SVG_LANGUAGE, + XML_LANGUAGE, + SVG_TAG_PREFIX, + SVG_SOURCE_ATTR, + SVG_RENDERED_ATTR, + SVG_INLINE_SHADOW_STYLE, + TOGGLE_SOURCE_BTN_CLASS, + DIAGRAM_VIEW_MODE_ATTR, + DIAGRAM_VIEW_RENDERED, + DIAGRAM_VIEW_SOURCE } from '$lib/constants'; import { ColorMode, UrlProtocol } from '$lib/enums'; import { FileTypeText } from '$lib/enums/files.enums'; import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils'; + import { sanitizeSvg } from '$lib/utils/sanitize-svg'; + import { mountSvgShadow } from '$lib/utils/svg-shadow'; import '$styles/katex-custom.scss'; import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; import githubLightCss from 'highlight.js/styles/github.css?inline'; @@ -77,11 +99,32 @@ let renderedBlocks = $state([]); let unstableBlockHtml = $state(''); let incompleteCodeBlock = $state(null); + const streamingSvgCode = $derived.by(() => { + const block = incompleteCodeBlock; + if (!block) return null; + if (block.language === SVG_LANGUAGE) return block.code; + if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX)) + return block.code; + return null; + }); + const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : ''); let previewDialogOpen = $state(false); let previewCode = $state(''); let previewLanguage = $state('text'); let mermaidPreviewOpen = $state(false); let mermaidPreviewSvgHtml = $state(''); + let svgPreviewLive = $state(false); + let streamingSvgHost = $state(null); + + // While the zoom dialog is open on a streaming svg, mirror the live render into it + $effect(() => { + if (svgPreviewLive && liveSvgHtml) mermaidPreviewSvgHtml = liveSvgHtml; + }); + + // Mount the streaming svg into its shadow host on every chunk so it renders live + $effect(() => { + if (streamingSvgHost) mountSvgShadow(streamingSvgHost, liveSvgHtml, SVG_INLINE_SHADOW_STYLE); + }); let streamingCodeScrollContainer = $state(); @@ -124,8 +167,10 @@ .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,
    ) inside Markdown tables .use(rehypeEnhanceLinks) // Add target="_blank" to links .use(rehypeMermaidPre) // Convert mermaid blocks to
    +			.use(rehypeSvgPre) // Convert svg blocks to 
     			.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
     			.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
    +			.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
     			.use(rehypeResolveAttachmentImages, { attachments })
     			.use(rehypeRtlSupport) // Add bidirectional text support
     			.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
    @@ -461,18 +506,37 @@
     	async function handleMermaidClick(event: MouseEvent) {
     		const target = event.target as HTMLElement;
     
    +		// Toggle a diagram block between its rendered view and its source view.
    +		// Shared by mermaid and svg, css drives the visibility from the wrapper mode.
    +		const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
    +		if (toggleBtn) {
    +			event.preventDefault();
    +			event.stopPropagation();
    +
    +			const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
    +			if (!wrapper) return;
    +
    +			const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
    +			const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
    +			wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
    +			toggleBtn.setAttribute('aria-pressed', String(!isSource));
    +			return;
    +		}
    +
     		// Check if clicking on copy or preview button in mermaid block
    -		const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
    -		const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
    +		const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
    +		const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
     
     		if (copyBtn || previewBtn) {
    -			const wrapper = target.closest('.mermaid-block-wrapper');
    +			const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
     			if (!wrapper) return;
     
    -			const preElement = wrapper.querySelector('pre.mermaid[data-mermaid-syntax]');
    +			const preElement = wrapper.querySelector(
    +				`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
    +			);
     			if (!preElement) return;
     
    -			const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
    +			const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
     
     			if (copyBtn) {
     				event.preventDefault();
    @@ -491,19 +555,75 @@
     				const svg = preElement.querySelector('svg');
     				if (!svg) return;
     				mermaidPreviewSvgHtml = svg.outerHTML;
    +				svgPreviewLive = false;
    +				mermaidPreviewOpen = true;
    +				return;
    +			}
    +		}
    +
    +		// Check if clicking on copy or preview button in svg block
    +		const svgCopyBtn = target.closest(`.${SVG_WRAPPER_CLASS} .copy-code-btn`);
    +		const svgPreviewBtn = target.closest(`.${SVG_WRAPPER_CLASS} .preview-code-btn`);
    +
    +		if (svgCopyBtn || svgPreviewBtn) {
    +			const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
    +			if (!wrapper) return;
    +
    +			const preElement = wrapper.querySelector(
    +				`pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
    +			);
    +			if (!preElement) return;
    +
    +			if (svgCopyBtn) {
    +				event.preventDefault();
    +				event.stopPropagation();
    +				try {
    +					await copyToClipboard(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
    +				} catch (error) {
    +					console.error('Failed to copy svg source:', error);
    +				}
    +				return;
    +			}
    +
    +			if (svgPreviewBtn) {
    +				event.preventDefault();
    +				event.stopPropagation();
    +				mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
    +				svgPreviewLive = false;
     				mermaidPreviewOpen = true;
     				return;
     			}
     		}
     
    +		// A click on the header chrome targets the action buttons, never the
    +		// diagram. Guard so a header click can not fall through to the click to
    +		// zoom branches below, whatever the scroll position or stacking.
    +		if (target.closest(`.${CODE_BLOCK_HEADER_CLASS}`)) return;
    +
    +		// Open preview when clicking the svg block itself. A final block carries its
    +		// source, a streaming block does not and is mirrored live into the dialog.
    +		const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
    +		if (svgEl) {
    +			const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
    +			if (source !== null) {
    +				mermaidPreviewSvgHtml = sanitizeSvg(source);
    +				svgPreviewLive = false;
    +			} else {
    +				svgPreviewLive = true;
    +			}
    +			mermaidPreviewOpen = true;
    +			return;
    +		}
    +
     		// Otherwise, open preview when clicking on the mermaid diagram itself
    -		const mermaidEl = target.closest('.mermaid');
    +		const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
     		if (!mermaidEl) return;
     
     		const svg = mermaidEl.querySelector('svg');
     		if (!svg) return;
     
     		mermaidPreviewSvgHtml = svg.outerHTML;
    +		svgPreviewLive = false;
     		mermaidPreviewOpen = true;
     	}
     
    @@ -515,6 +635,7 @@
     		mermaidPreviewOpen = open;
     		if (!open) {
     			mermaidPreviewSvgHtml = '';
    +			svgPreviewLive = false;
     		}
     	}
     
    @@ -527,12 +648,14 @@
     	async function renderMermaidDiagrams() {
     		if (!containerRef) return;
     
    -		const nodes = containerRef.querySelectorAll('pre.mermaid:not([data-mermaid-rendered])');
    +		const nodes = containerRef.querySelectorAll(
    +			`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
    +		);
     		if (nodes.length === 0) return;
     
     		// Mark nodes immediately to prevent duplicate renders if called again during streaming.
     		// This avoids needing a guard that would block node discovery.
    -		nodes.forEach((node) => node.setAttribute('data-mermaid-rendered', 'true'));
    +		nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, 'true'));
     
     		// Read mode before await so Svelte tracks it reactively.
     		const isDark = mode.current === ColorMode.DARK;
    @@ -565,6 +688,34 @@
     		}
     	}
     
    +	/**
    +	 * Renders svg diagrams that haven't been rendered yet.
    +	 * Sanitizes the source before injecting and marks each node so it renders once.
    +	 * An empty sanitize result keeps the raw source as escaped text.
    +	 */
    +	function renderSvgDiagrams() {
    +		if (!containerRef) return;
    +
    +		const nodes = containerRef.querySelectorAll(
    +			`pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
    +		);
    +		if (nodes.length === 0) return;
    +
    +		nodes.forEach((node) => {
    +			node.setAttribute(SVG_RENDERED_ATTR, 'true');
    +
    +			const source = node.getAttribute(SVG_SOURCE_ATTR) ?? node.textContent ?? '';
    +			const clean = sanitizeSvg(source);
    +
    +			if (clean) {
    +				node.textContent = '';
    +				const host = document.createElement('div');
    +				node.appendChild(host);
    +				mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
    +			}
    +		});
    +	}
    +
     	/**
     	 * Handles image load errors by replacing the image with a fallback UI.
     	 * Shows a placeholder with a link to open the image in a new tab.
    @@ -647,6 +798,7 @@
     			setupCodeBlockActions();
     			setupImageErrorHandlers();
     			renderMermaidDiagrams();
    +			renderSvgDiagrams();
     		}
     	});
     
    @@ -689,7 +841,7 @@
     	{/if}
     
     	{#if incompleteCodeBlock}
    -		{#if incompleteCodeBlock.language === 'mermaid'}
    +		{#if incompleteCodeBlock.language === MERMAID_LANGUAGE}
     			
    mermaid @@ -705,6 +857,30 @@ Generating diagram...
    + {:else if streamingSvgCode !== null} +
    +
    + svg +
    + +
    +
    + {#if liveSvgHtml} +
    +
    +
    +
    +
    + {:else} +
    + Rendering svg... +
    + {/if} +
    {:else}
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css index 07904f76812b..41813f4fda76 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -19,8 +19,16 @@ line-height: 1.75; } +.markdown-content :global(.markdown-block:first-child p:first-child) { + margin-block-start: 0; +} + +.markdown-content :global(.markdown-block:last-child p:last-child) { + margin-block-end: 0; +} + .markdown-content :global(:is(h1, h2, h3, h4, h5, h6):first-child) { - margin-top: 0; + margin-top: 0.5rem; } /* Headers with consistent spacing */ @@ -300,7 +308,8 @@ div.markdown-user-content :global(.table-wrapper) { } .markdown-content :global(.copy-code-btn), -.markdown-content :global(.preview-code-btn) { +.markdown-content :global(.preview-code-btn), +.markdown-content :global(.toggle-source-btn) { display: flex; align-items: center; justify-content: center; @@ -312,15 +321,22 @@ div.markdown-user-content :global(.table-wrapper) { } .markdown-content :global(.copy-code-btn:hover), -.markdown-content :global(.preview-code-btn:hover) { +.markdown-content :global(.preview-code-btn:hover), +.markdown-content :global(.toggle-source-btn:hover) { transform: scale(1.05); } .markdown-content :global(.copy-code-btn:active), -.markdown-content :global(.preview-code-btn:active) { +.markdown-content :global(.preview-code-btn:active), +.markdown-content :global(.toggle-source-btn:active) { transform: scale(0.95); } +/* Pressed state marks the source view as active */ +.markdown-content :global(.toggle-source-btn[aria-pressed='true']) { + color: var(--primary); +} + .markdown-content :global(.code-block-wrapper pre) { background: transparent; margin: 0; @@ -560,8 +576,9 @@ div.markdown-user-content :global(.table-wrapper) { border-color: var(--primary); } -/* Mermaid diagrams */ -.markdown-content :global(pre.mermaid) { +/* Mermaid and svg blocks share the same block styling */ +.markdown-content :global(pre.mermaid), +.markdown-content :global(.svg-block) { background: transparent; border: none; padding: 0; @@ -572,13 +589,25 @@ div.markdown-user-content :global(.table-wrapper) { position: relative; } +/* The svg block fills its flex container so the shadow host has a definite width to render into */ +.markdown-content :global(.svg-block) { + width: 100%; +} + /* Hide mermaid code text until rendered - prevents flash */ .markdown-content :global(pre.mermaid:not([data-mermaid-rendered])), .markdown-content :global(pre.mermaid[data-mermaid-rendered]:not(:has(svg))) { display: none; } -.markdown-content :global(pre.mermaid:hover) { +/* Hide svg source until rendered - prevents flash. A rendered-but-unsanitized + block (oversized source) keeps its raw text visible as a safe fallback. */ +.markdown-content :global(pre.svg-block:not([data-svg-rendered])) { + display: none; +} + +.markdown-content :global(pre.mermaid:hover), +.markdown-content :global(.svg-block:hover) { opacity: 0.85; } @@ -590,8 +619,9 @@ div.markdown-user-content :global(.table-wrapper) { padding: 3rem 1rem; } -/* Mermaid block wrapper - matches code block styling */ -.markdown-content :global(.mermaid-block-wrapper) { +/* Diagram block wrapper - matches code block styling */ +.markdown-content :global(.mermaid-block-wrapper), +.markdown-content :global(.svg-block-wrapper) { margin: 1.5rem 0; border-radius: 0.75rem; overflow: hidden; @@ -603,32 +633,39 @@ div.markdown-user-content :global(.table-wrapper) { max-height: var(--max-message-height); } -.markdown-content:global(.dark) :global(.mermaid-block-wrapper) { +.markdown-content:global(.dark) :global(.mermaid-block-wrapper), +.markdown-content:global(.dark) :global(.svg-block-wrapper) { border-color: color-mix(in oklch, var(--border) 20%, transparent); } -.markdown-content :global(.mermaid-scroll-container) { +.markdown-content :global(.mermaid-scroll-container), +.markdown-content :global(.svg-scroll-container) { min-height: 350px; max-height: var(--max-message-height); overflow-y: auto; overflow-x: auto; display: flex; - align-items: center; - justify-content: center; + align-items: safe center; + justify-content: safe center; padding: 3rem 1rem 1rem; } -.full-height-code-blocks :global(.mermaid-block-wrapper) { +.full-height-code-blocks :global(.mermaid-block-wrapper), +.full-height-code-blocks :global(.svg-block-wrapper) { max-height: none; } -.full-height-code-blocks :global(.mermaid-scroll-container) { +.full-height-code-blocks :global(.mermaid-scroll-container), +.full-height-code-blocks :global(.svg-scroll-container) { max-height: none; overflow-y: visible; } -/* Mermaid block uses same header styling as code blocks */ -.markdown-content :global(.mermaid-block-wrapper .code-block-header) { +/* Diagram block uses same header styling as code blocks. The header floats over + scrollable diagram content and stays transparent, so the overflow shows up to + the box edge. It keeps a z-index so it stays the click target above content. */ +.markdown-content :global(.mermaid-block-wrapper .code-block-header), +.markdown-content :global(.svg-block-wrapper .code-block-header) { display: flex; justify-content: space-between; align-items: center; @@ -638,16 +675,19 @@ div.markdown-user-content :global(.table-wrapper) { top: 0; left: 0; right: 0; + z-index: 2; } -.markdown-content :global(.mermaid-block-wrapper .code-block-actions) { +.markdown-content :global(.mermaid-block-wrapper .code-block-actions), +.markdown-content :global(.svg-block-wrapper .code-block-actions) { display: flex; align-items: center; gap: 0.5rem; } -/* Mermaid pre element - remove default margins */ -.markdown-content :global(.mermaid-block-wrapper pre.mermaid) { +/* Diagram pre element - remove default margins */ +.markdown-content :global(.mermaid-block-wrapper pre.mermaid), +.markdown-content :global(.svg-block-wrapper pre.svg-block) { background: transparent; border: none; padding: 0; @@ -655,7 +695,6 @@ div.markdown-user-content :global(.table-wrapper) { text-align: center; } -/* Mermaid SVG should be bigger */ .markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) { width: unset !important; height: auto; @@ -663,6 +702,31 @@ div.markdown-user-content :global(.table-wrapper) { padding: 3rem 1rem; } +/* Source view stays hidden while the block renders, css swaps the two views + from the wrapper mode so the click handler only flips one attribute. The view + reuses the code block scroll container, so it matches the app code blocks. */ +.markdown-content :global(.diagram-source) { + display: none; + text-align: left; +} + +.markdown-content :global(.diagram-source pre) { + background: transparent; + margin: 0; + border-radius: 0; + border: none; + font-size: 0.875rem; +} + +.markdown-content :global([data-view-mode='source'] .mermaid-scroll-container), +.markdown-content :global([data-view-mode='source'] .svg-scroll-container) { + display: none; +} + +.markdown-content :global([data-view-mode='source'] .diagram-source) { + display: block; +} + /* Streaming mermaid block - empty preview box */ .mermaid-streaming-block { min-height: 300px; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts index 554408485910..80052945f0c5 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts @@ -4,6 +4,7 @@ */ import { copyCodeToClipboard, copyToClipboard } from '$lib/utils'; +import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants'; export interface PreviewState { previewDialogOpen: boolean; @@ -106,17 +107,19 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) { const target = event.target as HTMLElement; // Check if clicking on copy or preview button in mermaid block - const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn'); - const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn'); + const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`); + const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`); if (copyBtn || previewBtn) { - const wrapper = target.closest('.mermaid-block-wrapper'); + const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`); if (!wrapper) return; - const preElement = wrapper.querySelector('pre.mermaid[data-mermaid-syntax]'); + const preElement = wrapper.querySelector( + `pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]` + ); if (!preElement) return; - const mermaidSyntax = preElement.dataset.mermaidSyntax ?? ''; + const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? ''; if (copyBtn) { event.preventDefault(); @@ -141,7 +144,7 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) { } // Otherwise, open preview when clicking on the mermaid diagram itself - const mermaidEl = target.closest('.mermaid'); + const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`); if (!mermaidEl) return; const svg = mermaidEl.querySelector('svg'); diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts index 732315464906..f1dd867e817b 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts @@ -7,12 +7,16 @@ import type { Element, ElementContent } from 'hast'; import { CODE_BLOCK_HEADER_CLASS, CODE_BLOCK_ACTIONS_CLASS, + CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_LANGUAGE_CLASS, COPY_CODE_BTN_CLASS, PREVIEW_CODE_BTN_CLASS, + TOGGLE_SOURCE_BTN_CLASS, + DIAGRAM_SOURCE_CLASS, RELATIVE_CLASS, COPY_ICON_SVG, - PREVIEW_ICON_SVG + PREVIEW_ICON_SVG, + CODE_ICON_SVG } from '$lib/constants'; export interface BlockIdGenerator { @@ -32,14 +36,16 @@ export function createIconElement(svg: string): Element { } /** - * Creates a button element with icon. + * Creates a button element with icon. Extra properties merge onto the button, + * which lets a stateful button carry attributes like aria-pressed. */ export function createButton( className: string, title: string, iconSvg: string, id: string, - idAttribute: string + idAttribute: string, + extraProperties: Record = {} ): Element { return { type: 'element', @@ -48,7 +54,8 @@ export function createButton( className: [className], [idAttribute]: id, title, - type: 'button' + type: 'button', + ...extraProperties }, children: [createIconElement(iconSvg)] }; @@ -72,6 +79,52 @@ export function createPreviewButton( return createButton(PREVIEW_CODE_BTN_CLASS, title, PREVIEW_ICON_SVG, id, idAttribute); } +/** + * Creates a button that toggles a diagram block between its rendered view and + * its source view. aria-pressed starts false, the rendered view is the default. + */ +export function createToggleSourceButton( + id: string, + idAttribute: string, + title: string = 'Toggle source' +): Element { + return createButton(TOGGLE_SOURCE_BTN_CLASS, title, CODE_ICON_SVG, id, idAttribute, { + 'aria-pressed': 'false' + }); +} + +/** + * Creates a source view for a diagram block. It reuses the code block scroll + * container so it matches the app code blocks, and wraps the highlighted code + * element captured at transform time. A missing code element falls back to a + * plain code node built from the raw source. + */ +export function createSourceView( + codeElement: Element | undefined, + source: string, + language: string +): Element { + const code: Element = codeElement ?? { + type: 'element', + tagName: 'code', + properties: { className: ['hljs', `language-${language}`] }, + children: [{ type: 'text', value: source }] + }; + return { + type: 'element', + tagName: 'div', + properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] }, + children: [ + { + type: 'element', + tagName: 'pre', + properties: {}, + children: [code] + } + ] + }; +} + /** * Creates a block header with language label and action buttons. */ @@ -116,14 +169,17 @@ export function createScrollContainer(preElement: Element, scrollContainerClass: } /** - * Creates a wrapper element with header and scroll container. + * Creates a wrapper element with header and scroll container. Extra children + * append after the scroll container, which lets a block carry a source view + * alongside its rendered output. */ export function createWrapper( header: Element, preElement: Element, wrapperClass: string, scrollContainerClass: string, - additionalAttributes?: Record + additionalAttributes?: Record, + extraChildren: Element[] = [] ): Element { return { type: 'element', @@ -132,7 +188,7 @@ export function createWrapper( className: [wrapperClass, RELATIVE_CLASS], ...additionalAttributes } as Element['properties'], - children: [header, createScrollContainer(preElement, scrollContainerClass)] + children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren] }; } diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts index ab24e78230f7..4007c20a19d7 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts @@ -13,11 +13,23 @@ import type { Plugin } from 'unified'; import type { Root, Element, ElementContent } from 'hast'; import { visit } from 'unist-util-visit'; -import { MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS } from '$lib/constants'; +import { + MERMAID_WRAPPER_CLASS, + MERMAID_SCROLL_CONTAINER_CLASS, + MERMAID_BLOCK_CLASS, + MERMAID_LANGUAGE, + MERMAID_SYNTAX_ATTR, + MERMAID_ID_ATTR, + DIAGRAM_VIEW_MODE_ATTR, + DIAGRAM_VIEW_RENDERED +} from '$lib/constants'; +import type { DiagramPreData } from './pre-transform'; import { createBlockHeader, createCopyButton, createPreviewButton, + createToggleSourceButton, + createSourceView, createWrapper, generateBlockId } from './code-block-utils'; @@ -43,11 +55,13 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => { const className = node.properties?.className; if (!Array.isArray(className)) return; - const isMermaid = className.some((cls) => typeof cls === 'string' && cls === 'mermaid'); + const isMermaid = className.some( + (cls) => typeof cls === 'string' && cls === MERMAID_BLOCK_CLASS + ); if (!isMermaid) return; - const mermaidId = generateBlockId('mermaid', 'idxMermaidBlock'); + const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock'); // Extract the mermaid syntax (text content of the pre element) const diagramText = node.children @@ -60,22 +74,29 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => { // Store the mermaid syntax in data attribute for copy functionality node.properties = { ...node.properties, - 'data-mermaid-syntax': diagramText, - 'data-mermaid-id': mermaidId + [MERMAID_SYNTAX_ATTR]: diagramText, + [MERMAID_ID_ATTR]: mermaidId }; const actions = [ - createCopyButton(mermaidId, 'data-mermaid-id', 'Copy mermaid syntax'), - createPreviewButton(mermaidId, 'data-mermaid-id', 'Preview diagram') + createCopyButton(mermaidId, MERMAID_ID_ATTR, 'Copy mermaid syntax'), + createToggleSourceButton(mermaidId, MERMAID_ID_ATTR, 'Toggle mermaid source'), + createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram') ]; - const header = createBlockHeader('mermaid', mermaidId, 'data-mermaid-id', actions); + const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions); + const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode; + const sourceView = createSourceView(preservedCode, diagramText, MERMAID_LANGUAGE); const wrapper = createWrapper( header, node, MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS, - { 'data-mermaid-id': mermaidId } + { + [MERMAID_ID_ATTR]: mermaidId, + [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED + }, + [sourceView] ); // Replace pre with wrapper in parent diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts new file mode 100644 index 000000000000..55bcb6065fda --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts @@ -0,0 +1,96 @@ +/** + * Rehype plugin to enhance svg blocks with wrapper, header, and action buttons. + * + * Wraps
     elements with a container that includes:
    + * - Language label ("svg")
    + * - Copy button (copies svg source to clipboard)
    + * - Preview button (opens fullscreen preview dialog)
    + *
    + * Operates directly on the HAST tree and reuses the shared code-block builders.
    + */
    +
    +import type { Plugin } from 'unified';
    +import type { Root, Element, ElementContent } from 'hast';
    +import { visit } from 'unist-util-visit';
    +import {
    +	SVG_WRAPPER_CLASS,
    +	SVG_SCROLL_CONTAINER_CLASS,
    +	SVG_BLOCK_CLASS,
    +	SVG_LANGUAGE,
    +	SVG_SOURCE_ATTR,
    +	SVG_ID_ATTR,
    +	DIAGRAM_VIEW_MODE_ATTR,
    +	DIAGRAM_VIEW_RENDERED
    +} from '$lib/constants';
    +import type { DiagramPreData } from './pre-transform';
    +import {
    +	createBlockHeader,
    +	createCopyButton,
    +	createPreviewButton,
    +	createToggleSourceButton,
    +	createSourceView,
    +	createWrapper,
    +	generateBlockId
    +} from './code-block-utils';
    +
    +declare global {
    +	interface Window {
    +		idxSvgBlock?: number;
    +	}
    +}
    +
    +export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
    +	return (tree: Root) => {
    +		visit(tree, 'element', (node: Element, index, parent) => {
    +			if (node.tagName !== 'pre' || !parent || index === undefined) return;
    +
    +			const className = node.properties?.className;
    +			if (!Array.isArray(className)) return;
    +
    +			const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
    +
    +			if (!isSvg) return;
    +
    +			const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
    +
    +			// Extract the svg source (text content of the pre element)
    +			const svgSource = node.children
    +				.map((child) => {
    +					if (child.type === 'text') return child.value;
    +					return '';
    +				})
    +				.join('');
    +
    +			// Store the svg source in data attribute for copy and render
    +			node.properties = {
    +				...node.properties,
    +				[SVG_SOURCE_ATTR]: svgSource,
    +				[SVG_ID_ATTR]: svgId
    +			};
    +
    +			const actions = [
    +				createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
    +				createToggleSourceButton(svgId, SVG_ID_ATTR, 'Toggle svg source'),
    +				createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
    +			];
    +
    +			const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
    +			const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
    +			const sourceView = createSourceView(preservedCode, svgSource, SVG_LANGUAGE);
    +			const wrapper = createWrapper(
    +				header,
    +				node,
    +				SVG_WRAPPER_CLASS,
    +				SVG_SCROLL_CONTAINER_CLASS,
    +				{
    +					[SVG_ID_ATTR]: svgId,
    +					[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
    +				},
    +				[sourceView]
    +			);
    +
    +			// Replace pre with wrapper in parent
    +			(parent.children as ElementContent[])[index] = wrapper;
    +		});
    +	};
    +};
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    index e2270a6583d6..61322f045cf1 100644
    --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    @@ -1,67 +1,7 @@
    -import type { Plugin } from 'unified';
    -import type { Root, Element, ElementContent, Text } from 'hast';
    -import { visit } from 'unist-util-visit';
    +import { createPreTransform } from './pre-transform';
    +import { MERMAID_BLOCK_CLASS, MERMAID_LANGUAGE } from '$lib/constants';
     
     /**
    - * Recursively extracts all text content from a HAST node.
    - * Handles nested elements (e.g., span wrappers from syntax highlighting).
    + * Converts mermaid code blocks to 
     for client-side rendering.
      */
    -function extractText(node: ElementContent): string {
    -	if (node.type === 'text') return node.value;
    -	if (node.type === 'element') {
    -		return (node.children ?? []).map(extractText).join('');
    -	}
    -	return '';
    -}
    -
    -/**
    - * Rehype plugin to convert mermaid code blocks to 
     elements.
    - *
    - * Transforms:
    - *   
    graph TD; A-->B
    - * into: - *
    graph TD; A-->B
    - * - * The mermaid library renders these client-side via mermaid.run(). - * - * Must run BEFORE rehypeEnhanceCodeBlocks so mermaid blocks are not wrapped - * with code block headers/buttons (they have no child, so they're skipped). - */ -export const rehypeMermaidPre: Plugin<[], Root> = () => { - return (tree: Root) => { - visit(tree, 'element', (node: Element, index, parent) => { - if (node.tagName !== 'pre' || !parent || index === undefined) return; - - const codeElement = node.children.find( - (child): child is Element => child.type === 'element' && child.tagName === 'code' - ); - - if (!codeElement) return; - - const className = codeElement.properties?.className; - if (!Array.isArray(className)) return; - - const isMermaid = className.some( - (cls) => typeof cls === 'string' && cls === 'language-mermaid' - ); - - if (!isMermaid) return; - - // Recursively extract text to handle nested spans from syntax highlighting - const diagramText = codeElement.children.map(extractText).join('').trim(); - - if (!diagramText) return; - - const mermaidPre: Element = { - type: 'element', - tagName: 'pre', - properties: { - className: ['mermaid'] - }, - children: [{ type: 'text', value: diagramText } as Text] - }; - - (parent.children as ElementContent[])[index] = mermaidPre; - }); - }; -}; +export const rehypeMermaidPre = createPreTransform(MERMAID_LANGUAGE, MERMAID_BLOCK_CLASS); diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts new file mode 100644 index 000000000000..7aa967bb81da --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts @@ -0,0 +1,91 @@ +import type { Plugin } from 'unified'; +import type { Root, Element, ElementContent, Text } from 'hast'; +import { visit } from 'unist-util-visit'; + +/** + * Metadata a diagram pre carries on its unist data field. The source code holds + * the highlighted code element captured before the pre became a render target, + * which the enhancer reuses to build a matching source view. + */ +export interface DiagramPreData { + sourceCode: Element; +} + +/** + * Recursively extracts all text content from a HAST node. + * Handles nested elements (e.g., span wrappers from syntax highlighting). + */ +function extractText(node: ElementContent): string { + if (node.type === 'text') return node.value; + if (node.type === 'element') { + return (node.children ?? []).map(extractText).join(''); + } + return ''; +} + +/** + * Builds a rehype plugin that converts
    
    + * blocks into 
     elements carrying the raw text.
    + *
    + * Accepts one or more source languages, and an optional contentGuard that
    + * receives the trimmed text and decides whether the block qualifies. The guard
    + * lets a shared fence language be claimed only when its content matches, e.g.
    + * an xml block is converted to svg only when it starts with  child, so rehypeEnhanceCodeBlocks skips it. Rendering
    + * happens client-side, so no markup is injected at this stage. Must run BEFORE
    + * rehypeEnhanceCodeBlocks.
    + */
    +export function createPreTransform(
    +	languages: string | string[],
    +	targetClass: string,
    +	contentGuard?: (text: string) => boolean
    +): Plugin<[], Root> {
    +	const codeClasses = (Array.isArray(languages) ? languages : [languages]).map(
    +		(language) => `language-${language}`
    +	);
    +
    +	return () => {
    +		return (tree: Root) => {
    +			visit(tree, 'element', (node: Element, index, parent) => {
    +				if (node.tagName !== 'pre' || !parent || index === undefined) return;
    +
    +				const codeElement = node.children.find(
    +					(child): child is Element => child.type === 'element' && child.tagName === 'code'
    +				);
    +
    +				if (!codeElement) return;
    +
    +				const className = codeElement.properties?.className;
    +				if (!Array.isArray(className)) return;
    +
    +				const matches = className.some(
    +					(cls) => typeof cls === 'string' && codeClasses.includes(cls)
    +				);
    +
    +				if (!matches) return;
    +
    +				// Recursively extract text to handle nested spans from syntax highlighting
    +				const text = codeElement.children.map(extractText).join('').trim();
    +
    +				if (!text) return;
    +
    +				if (contentGuard && !contentGuard(text)) return;
    +
    +				const pre: Element = {
    +					type: 'element',
    +					tagName: 'pre',
    +					properties: {
    +						className: [targetClass]
    +					},
    +					children: [{ type: 'text', value: text } as Text],
    +					// Keep the highlighted code element so the block can offer a source
    +					// view that matches the app code blocks without re highlighting.
    +					data: { sourceCode: codeElement } satisfies DiagramPreData
    +				};
    +
    +				(parent.children as ElementContent[])[index] = pre;
    +			});
    +		};
    +	};
    +}
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts
    new file mode 100644
    index 000000000000..eb0e2c699bf5
    --- /dev/null
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts
    @@ -0,0 +1,13 @@
    +import { createPreTransform } from './pre-transform';
    +import { SVG_BLOCK_CLASS, SVG_LANGUAGE, XML_LANGUAGE, SVG_TAG_PREFIX } from '$lib/constants';
    +
    +/**
    + * Converts svg code blocks to 
     for client-side rendering.
    + * Also claims xml blocks whose content starts with  text.startsWith(SVG_TAG_PREFIX)
    +);
    diff --git a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    index d4825889d268..a30f585b93cb 100644
    --- a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    +++ b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    @@ -1,5 +1,7 @@
     
     
     
    - +
    {@html highlightedHtml}
    diff --git a/tools/ui/src/lib/components/app/content/index.ts b/tools/ui/src/lib/components/app/content/index.ts index 5d2884bb214c..5cfdd1b9c1e0 100644 --- a/tools/ui/src/lib/components/app/content/index.ts +++ b/tools/ui/src/lib/components/app/content/index.ts @@ -68,7 +68,6 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte * ```svelte * @@ -78,6 +77,22 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte */ export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte'; +/** + * **CollapsibleTerminalBlock** - Expandable content card with a terminal-style frame + * + * Same shape as CollapsibleContentBlock, but with a `code-background` + * fill, subtle border, and tightened padding suited for shell command + * output and similar dense / monospace content. + * + * @example + * ```svelte + * + *
    {output}
    + *
    + * ``` + */ +export { default as CollapsibleTerminalBlock } from './CollapsibleTerminalBlock.svelte'; + /** * **MermaidPreview** - Interactive Mermaid diagram viewer * diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte index eb162a557292..f741b544bb7a 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte @@ -1,4 +1,5 @@ - + - Add New Server + Add New MCP Server -
    - (newServerUrl = v)} - onHeadersChange={(v) => (newServerHeaders = v)} - urlError={newServerUrl ? newServerUrlError : null} - id="new-server" - /> -
    - - - - - - + {#if recommendationsToShow.length > 0} +
    +
    +

    Recommended Servers

    + +
    + +
    + {#each recommendationsToShow as recommendation (recommendation.id)} + handleRecommendationClick(recommendation.id)} + selected={selectedRecommendationId === recommendation.id} + dimmed={hasSelection && selectedRecommendationId !== recommendation.id} + /> + {/each} +
    +
    + {/if} + +
    +
    + (newServerUrl = v)} + onHeadersChange={(v) => (newServerHeaders = v)} + onUseProxyChange={(v) => (newServerUseProxy = v)} + urlError={newServerUrl ? newServerUrlError : null} + id="new-server" + bind:wantsAuthorization={newServerWantsAuthorization} + required={authRequired} + /> +
    + + + + + + +
    diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte index a6c20291fa0a..89d23cd4b292 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte @@ -1,4 +1,5 @@
    {#if sectionLabel} - + {sectionLabel} {#if sectionLabelOptional} (optional) @@ -98,11 +118,13 @@ {addButtonLabel}
    + {#if pairs.length > 0}
    {#each pairs as pair, index (index)}
    {:else} -

    {emptyMessage}

    +

    {emptyMessage}

    {/if}
    diff --git a/tools/ui/src/lib/components/app/forms/SearchInput.svelte b/tools/ui/src/lib/components/app/forms/SearchInput.svelte index 19dd7e6a7efa..2d29672c4d9e 100644 --- a/tools/ui/src/lib/components/app/forms/SearchInput.svelte +++ b/tools/ui/src/lib/components/app/forms/SearchInput.svelte @@ -1,8 +1,10 @@ + + +
    + {#if activeIconUrl} + + {/if} + +

    {server.name}

    +
    + +

    {server.description}

    +
    diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte index 6727a90006b6..8ed4ee8b8023 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardEditForm.svelte @@ -1,6 +1,7 @@ -
    -

    Configure Server

    +
    +
    +

    Configure Server

    - (editUrl = v)} - onHeadersChange={(v) => (editHeaders = v)} - onUseProxyChange={(v) => (editUseProxy = v)} - urlError={editUrl ? urlError : null} - id={serverId} - /> + (editUrl = v)} + onHeadersChange={(v) => (editHeaders = v)} + onUseProxyChange={(v) => (editUseProxy = v)} + urlError={editUrl ? urlError : null} + id={serverId} + /> -
    - +
    + - + +
    -
    +
    diff --git a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte index 79738e30dd16..a7472add3196 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte @@ -5,9 +5,14 @@ import type { KeyValuePair } from '$lib/types'; import { parseHeadersToArray, serializeHeaders } from '$lib/utils'; import { UrlProtocol } from '$lib/enums'; - import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants'; + import { + AUTHORIZATION_HEADER, + BEARER_PREFIX, + CLI_FLAGS, + MCP_SERVER_URL_PLACEHOLDER, + REDACTED_HEADERS + } from '$lib/constants'; import { mcpStore } from '$lib/stores/mcp.svelte'; - import { CLI_FLAGS } from '$lib/constants'; interface Props { url: string; @@ -18,6 +23,22 @@ onUseProxyChange?: (useProxy: boolean) => void; urlError?: string | null; id?: string; + /** + * "Wants Authorization" is the user's *intent* to add a Bearer token + * (separate from `hasAuthorization` which reflects what's already in + * the headers). Bindable so a parent - e.g. the recommendation cards + * on the "Add New Server" dialog - can flip the switch on when the + * picked server ships a `needsAuthorization: true` flag. + */ + wantsAuthorization?: boolean; + /** + * Marks the "Authorization" field as required. Locks the toggle so the + * user can't dismiss it, and visually marks the field with a red + * asterisk. The parent is expected to gate its submit affordance on + * the bearer token actually being filled. Used by the "Add New Server" + * dialog for recommendations whose `needsAuthorization` flag is true. + */ + required?: boolean; } let { @@ -28,7 +49,9 @@ onHeadersChange, onUseProxyChange, urlError = null, - id = 'server' + id = 'server', + wantsAuthorization = $bindable(false), + required = false }: Props = $props(); let isWebSocket = $derived( @@ -38,15 +61,83 @@ let headerPairs = $derived(parseHeadersToArray(headers)); + // Heuristic: this dedicated UI only owns Authorization headers that already + // carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the + // KV section so the user can still edit those values verbatim. + const matchesAuthorizationKey = (key: string): boolean => + REDACTED_HEADERS.has(key.trim().toLowerCase()); + + const isBearerScheme = (value: string): boolean => + value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase()); + + const ownedByBearerUi = (p: KeyValuePair): boolean => + matchesAuthorizationKey(p.key) && isBearerScheme(p.value); + + let hasAuthorization = $derived(headerPairs.some(ownedByBearerUi)); + + let showAuthorization = $derived(hasAuthorization || wantsAuthorization); + + let urlInput: HTMLInputElement | null = $state(null); + let bearerInput: HTMLInputElement | null = $state(null); + + $effect(() => { + urlInput?.focus(); + }); + + $effect(() => { + if (wantsAuthorization && bearerInput) { + bearerInput.focus(); + } + }); + + let bearerToken = $derived.by(() => { + const auth = headerPairs.find(ownedByBearerUi); + if (!auth) return ''; + return auth.value.trim().slice(BEARER_PREFIX.length).trim(); + }); + + $effect(() => { + if (!headers.trim()) { + wantsAuthorization = false; + } + }); + function updateHeaderPairs(newPairs: KeyValuePair[]) { headerPairs = newPairs; onHeadersChange(serializeHeaders(newPairs)); } + + // The dedicated UI owns the Authorization slot end-to-end when the user + // engages it: any prior Authorization row (Bearer or otherwise) is replaced + // by exactly one { Authorization: "Bearer " } entry. JSON's last-key + // behavior would otherwise pick one arbitrarily, so we strip first. + function updateBearerToken(token: string) { + const filtered = headerPairs.filter((p) => !matchesAuthorizationKey(p.key)); + + const trimmed = token.trim(); + + if (trimmed) { + filtered.push({ key: AUTHORIZATION_HEADER, value: `${BEARER_PREFIX}${trimmed}` }); + } + + updateHeaderPairs(filtered); + } + + function setUseAuthorization(checked: boolean) { + wantsAuthorization = checked; + + if (!checked) { + // Only drop the entry this UI owns; a non-Bearer Authorization row + // authored in the KV section must survive a toggle off untouched. + const filtered = headerPairs.filter((p) => !ownedByBearerUi(p)); + updateHeaderPairs(filtered); + } + } -
    -
    -
    diff --git a/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte b/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte index feafc5d8113d..3f128e02c967 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerIdentity.svelte @@ -1,6 +1,7 @@ + +
    + {@html logoMark} +
    + + diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts index 64b76fb711dc..b550ae66a53c 100644 --- a/tools/ui/src/lib/components/app/misc/index.ts +++ b/tools/ui/src/lib/components/app/misc/index.ts @@ -51,3 +51,11 @@ export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte'; * Preview button is shown only for HTML code blocks. */ export { default as CodeBlockActions } from './CodeBlockActions.svelte'; + +/** + * **Logo** - Application brand mark + * + * Inline SVG of the application logo. Accepts styling via the standard + * `class` and `style` props and inherits color via `currentColor`. + */ +export { default as Logo } from './Logo.svelte'; diff --git a/tools/ui/src/lib/components/app/models/ModelLoadHighlight.svelte b/tools/ui/src/lib/components/app/models/ModelLoadHighlight.svelte new file mode 100644 index 000000000000..fa9a02108a0b --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelLoadHighlight.svelte @@ -0,0 +1,11 @@ + + + +
    +
    +
    diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte index 40006a4c9359..720963a8db9d 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte @@ -2,8 +2,10 @@ import { ChevronDown, Loader2, Package } from '@lucide/svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Tooltip from '$lib/components/ui/tooltip'; - import { KeyboardKey } from '$lib/enums'; + import { KeyboardKey, ServerModelStatus } from '$lib/enums'; import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte'; + import { modelsStore, routerModels } from '$lib/stores/models.svelte'; + import { modelLoadFraction } from '$lib/utils'; import { DialogModelInformation, DropdownMenuSearchable, @@ -11,6 +13,7 @@ ModelsSelectorList, ModelsSelectorOption } from '$lib/components/app'; + import ModelLoadHighlight from './ModelLoadHighlight.svelte'; import type { ModelItem } from './utils'; interface Props { @@ -113,6 +116,17 @@ {/if} {:else} {@const selectedOption = ms.getDisplayOption()} + {@const triggerModel = selectedOption?.model} + {@const triggerStatus = triggerModel + ? routerModels().find((m) => m.id === triggerModel)?.status?.value + : undefined} + {@const triggerLoading = + !!triggerModel && + (triggerStatus === ServerModelStatus.LOADING || + modelsStore.isModelOperationInProgress(triggerModel))} + {@const triggerLoadPercent = triggerLoading + ? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100) + : 0} {#if ms.isRouter} @@ -123,7 +137,7 @@ {/if} + + {#if triggerLoading} + + {/if} {/snippet} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte index d103d4b6711e..9671615a4f84 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte @@ -1,4 +1,5 @@
    onSelect(option.id)} onmouseenter={onMouseEnter} @@ -79,7 +87,7 @@
    e.stopPropagation()} > {#if isFav} @@ -113,12 +121,16 @@
    {#if isLoading} - +
    + +
    {:else if isFailed} -
    - +
    + -