-
- {#if !isEmpty} - { - autoScroll.enable(); - if (!autoScroll.userScrolledUp) { - autoScroll.scrollToBottom(); - } - }} - onMessagesReady={handleMessagesReady} - /> + {#if !isEmpty} + { + handleSendLikeScroll(); + }} + /> + {/if} + +
+ + + + + {#if page.params.id} + {/if} -
- {#if isEmpty} -
-

Hello there

- -

- {serverStore.props?.modalities?.audio - ? 'Record audio, type a message ' - : 'Type a message'} or upload files to get started -

-
- {/if} - - {#if page.params.id} - - {/if} - - {#if hasPropsError} -
- - - - Server unavailable - - - {serverError()} - -
- {/if} - -
- chatStore.stopGeneration()} - onSystemPromptAdd={handleSystemPromptAdd} - bind:uploadedFiles +
+ {#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id} + { + mobileScrollDownHint = false; + scroll.chatScrollContainer?.scrollTo({ + behavior: 'smooth', + top: scroll.chatScrollContainer.scrollHeight + }); + }} /> -
+ {/if}
+ + chatStore.stopGeneration()} + onSystemPromptAdd={handleSystemPromptAdd} + />
{/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 new file mode 100644 index 000000000000..655c34bb2f63 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte @@ -0,0 +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..feba9ee35d7a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte @@ -0,0 +1,55 @@ + + + + + (showDeleteDialog = false)} + onConfirm={handleDeleteConfirm} + title="Delete Conversation" + variant="destructive" +/> + + { + 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..9825b4b90b38 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte @@ -2,8 +2,9 @@ import { afterNavigate } from '$app/navigation'; import { page } from '$app/state'; import { ChatForm } from '$lib/components/app'; - import { onMount } from 'svelte'; import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte'; + import { deviceStore } from '$lib/stores'; + import { onMount } from 'svelte'; interface Props { class?: string; @@ -32,7 +33,33 @@ }: 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); @@ -40,11 +67,11 @@ const { clearDraft } = useDraftMessages({ getChatId: () => chatId, - getMessage: () => message, getFiles: () => uploadedFiles, - setMessage: (m) => (message = m), + getInitialMessage: () => initialMessage, + getMessage: () => message, setFiles: (f) => (uploadedFiles = f), - getInitialMessage: () => initialMessage + setMessage: (m) => (message = m) }); function handleFilesAdd(files: File[]) { @@ -75,20 +102,32 @@ } function handleSystemPromptClick() { - onSystemPromptAdd?.({ message, files: uploadedFiles }); + onSystemPromptAdd?.({ files: uploadedFiles, message }); } function handleUploadedFileRemove(fileId: string) { onFileRemove?.(fileId); } + // Auto-focus must not steal focus already claimed elsewhere (e.g. the system + // message editor opened just before a navigation) + function focusFormUnlessCaptured() { + const active = document.activeElement; + + if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) return; + + chatFormRef?.focus(); + } + onMount(() => { - setTimeout(() => chatFormRef?.focus(), 10); + if (!deviceStore.isMobile) { + setTimeout(focusFormUnlessCaptured, 100); + } }); afterNavigate((navigation) => { - if (navigation?.from != null) { - setTimeout(() => chatFormRef?.focus(), 10); + if (navigation?.from != null && !deviceStore.isMobile) { + setTimeout(focusFormUnlessCaptured, 100); } }); @@ -101,26 +140,26 @@ $effect(() => { if (previousIsLoading && !isLoading) { - setTimeout(() => chatFormRef?.focus(), 10); + setTimeout(focusFormUnlessCaptured, 10); } previousIsLoading = isLoading; }); -
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte new file mode 100644 index 000000000000..5af00ebb479b --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte @@ -0,0 +1,23 @@ + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte deleted file mode 100644 index b5979db13c2f..000000000000 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - -
-
- {#each processingDetails as detail (detail)} - {detail} - {/each} -
-
- - diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte new file mode 100644 index 000000000000..cf9f55fd7f1b --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte @@ -0,0 +1,40 @@ + + +{#if hasError} +
+ + {#if isLoadingModel} + + {:else} + + {/if} + + + {isLoadingModel ? 'Loading model' : 'Server unavailable'} + + {#if !isLoadingModel} + + {/if} + + + {#if !isLoadingModel} + {serverStore.error} + {/if} + +
+{/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..4fd1023dd647 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenStreamResumeStatus.svelte @@ -0,0 +1,19 @@ + + +{#if state === StreamConnectionState.RESUMING} +
+ + + Reconnecting to the stream... +
+{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte new file mode 100644 index 000000000000..423a746e4934 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte @@ -0,0 +1,137 @@ + + + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte new file mode 100644 index 000000000000..ba223c4ad0e1 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte @@ -0,0 +1,156 @@ + + + +
+ onAuxClick?.(tab.id, e)} + onclick={handleActivate} + > + + {#if isLoading} + + + {#snippet child({ props })} + + {/snippet} + + + +

Stop generation

+
+
+ {/if} + + {#if tab.isNewChat} + + {/if} + + {tab.name} + + + + {#snippet child({ props })} + + {/snippet} + + + +

Close tab

+
+
+
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte new file mode 100644 index 000000000000..52b28106fa03 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte @@ -0,0 +1,30 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + +

New chat

+
+
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index 5f65979803cc..a96ae378919d 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -91,7 +91,7 @@ export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachme * preview without carousel, or a gallery/carousel view when multiple items exist. * Uses ChatAttachmentPreviewSingle internally for each item's content. */ -export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte'; +export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte'; export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte'; export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte'; export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte'; @@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing. * * **Architecture:** - * - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts + * - Composes ChatFormInput (a plain textarea, or a ChatFormInputRich for + * messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts * - Manages file upload state via `uploadedFiles` bindable prop * - Integrates with ModelsSelectorDropdown for model selection in router mode * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.) @@ -240,10 +241,24 @@ export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormAct */ export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte'; +/** + * Dropdown submenu for selecting reasoning effort level. + * + * 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 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. */ -export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte'; +export { default as ChatFormInputFileInputInvisible } from './ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte'; /** * Displays MCP Resource attachments as a horizontal carousel. @@ -252,11 +267,23 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte'; /** - * Auto-resizing textarea with IME composition support. Automatically adjusts - * height based on content. Handles IME input correctly (waits for composition - * end before processing Enter key). Exposes focus() and resetHeight() methods. + * The message editor. Renders a plain auto-resizing textarea by default, + * or a ChatFormInputRich that renders `[name](file://...)` mention links as + * inline chips (keeping the value as the markdown source string) once a + * mention link lands in the buffer. The variant is selected via the + * `useRichInput` prop; both share one imperative handle. + */ +export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte'; + +/** + * Working directory selector for agent mode. Renders a chip below the chat + * form; clicking it opens a popover with a directory picker backed by the + * server's `file_glob_search` server tool (POST /tools). The picked + * directory is exposed via `bind:directory`; changing it records a + * synthetic "Set working directory to ..." user message into chat history + * and is enforced on tool calls via the `x-tool-cwd` request header. */ -export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'; +export { default as ChatFormCurrentWorkingDirectory } from './ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte'; /** * **ChatFormPickerMcpPrompts** - MCP prompt selection interface @@ -327,14 +354,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha * Generic scrollable list for picker popovers. Provides search input, * scroll-into-view for keyboard navigation, loading skeletons, empty state, * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering. - * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. + * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention. */ export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte'; /** * Generic button wrapper for picker list items. Provides consistent styling, * hover/selected states, and data-picker-index attribute for scroll-into-view. - * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. + * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention. */ export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte'; @@ -352,30 +379,23 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte'; /** - * **ChatFormPickerMcpResources** - MCP resource selection interface - * - * Floating picker for browsing and attaching MCP Server Resources. - * Triggered by typing `@` in the chat input. - * Loads resources from connected MCP servers and allows users to attach them to the chat context. - * - * **Features:** - * - Search/filter resources by name, title, description, or URI across all connected servers - * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close) - * - Shows attached state for already-attached resources - * - Loading states with skeleton placeholders - * - Server information header per resource for visual identification - * - * **Exported API:** - * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled + * `@`-triggered file/folder mention picker. Resolves `@` in the chat + * input to a filesystem match via the server's `file_glob_search` server tool + * tool, scoped to the conversation cwd (or server home when unset). + * Selection splices a `[name](file:///)` link into the input. */ -export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte'; +export { default as ChatFormPickerMention } from './ChatForm/ChatFormPickers/ChatFormPickerMention.svelte'; /** - * **ChatFormPickers** - Chat input picker container - * - * Container component that hosts both MCP prompt and MCP resource pickers. - * Manages shared state, keyboard navigation, and coordination between the two - * picker interfaces. Used within ChatForm for `@`-triggered pickers. + * `/`-triggered slash-command picker. Lists the available slash commands + * (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection + * hands the command to the parent for dispatch. + */ +export { default as ChatFormPickerCommand } from './ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte'; + +/** + * Hosts the chat-form pickers (slash-command, MCP prompt, file mention) + * and delegates keyboard events to the active one. */ export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte'; @@ -543,6 +563,22 @@ export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessag */ export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte'; +/** + * Synthetic working-directory-change message. Rendered in place of a user + * bubble when the message content parses as a cwd message (see + * parseCwdMessage); shows the new cwd with the same folder-row treatment + * the tool-call UI used. + */ +export { default as ChatMessageCwdChange } from './ChatMessages/ChatMessage/ChatMessageCwdChange.svelte'; + +/** + * Generic wrapper for UI-generated (synthetic) messages. Routes the + * working-directory change to ChatMessageCwdChange and renders a muted + * fallback for any other synthetic text, so no synthetic message ever + * surfaces as a user bubble. + */ +export { default as ChatMessageSynthetic } from './ChatMessages/ChatMessage/ChatMessageSynthetic.svelte'; + /** * Formatted content display for MCP prompt messages. Renders the full prompt * content with arguments in a readable format. Used within ChatMessageMcpPrompt @@ -557,6 +593,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 @@ -646,6 +686,18 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMes */ export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte'; +/** + * **ChatTabs** - Browser-style tab bar for open conversations + * + * Horizontal strip of tabs rendered above ChatScreen in the chat layout, + * one per conversation tracked by tabsStore. The active tab follows the + * route's conversation id; clicking a tab navigates to it, middle-click or + * the close button closes it (switching to the left neighbor when closing + * the active tab), and a trailing "+" button starts a new chat. Shows a + * spinner on tabs with a running generation. Desktop-only. + */ +export { default as ChatTabs } from './ChatTabs/ChatTabs.svelte'; + /** * Visual overlay displayed when user drags files over the chat screen. * Shows drop zone indicator to guide users where to release files. @@ -661,9 +713,16 @@ 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. + * 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 ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte'; +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 b7297ab6b1aa..c54b981cde8e 100644 --- a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte +++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte @@ -1,9 +1,8 @@ { open = value; onToggle?.(); }} - class={className} + {open} > - - -
- {#if IconComponent} - - {/if} - - {title} + +
+ {#if iconUrl} + + {:else if IconComponent} + + {/if} - {#if subtitle} - {subtitle} + + {#if titleSnippet} + {@render titleSnippet()} + {:else} + {title} {/if} -
+ -
- + {#if subtitle} + {subtitle} + {/if} +
- Toggle content -
-
+ + + Toggle content + - -
- {@render children()} + + + {#if open} +
+
+ {@render children()} +
-
- + {/if} + 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..0ad6ea61fc9d --- /dev/null +++ b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte @@ -0,0 +1,101 @@ + + + { + open = value; + onToggle?.(); + }} + {open} + 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 +
+ + + + {#if open} +
+ {@render children()} +
+ {/if} +
+
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 c1b71e4519ef..87b41bd00dee 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -1,44 +1,76 @@ + +
{#each renderedBlocks as block (block.id)} -
- +
{@html block.html}
{/each} {#if unstableBlockHtml} -
+
{@html unstableBlockHtml}
{/if} {#if incompleteCodeBlock} -
-
- {incompleteCodeBlock.language || 'text'} - { - previewCode = code; - previewLanguage = lang; - previewDialogOpen = true; - }} - /> + {#if incompleteCodeBlock.language === MERMAID_LANGUAGE} +
+
+ mermaid + +
+ +
+
+ +
+ Generating diagram... +
-
streamingAutoScroll.handleScroll()} - > -
{@html highlightCode(
-							incompleteCodeBlock.code,
-							incompleteCodeBlock.language || 'text'
-						)}
+ {:else if streamingSvgCode !== null} +
+
+ svg + +
+ +
+
+ + {#if liveSvgHtml} +
+
+
+
+
+ {:else} +
+ Rendering svg... +
+ {/if}
-
+ {:else} +
+
+ {incompleteCodeBlock.language || 'text'} + + { + previewCode = code; + previewLanguage = lang; + previewDialogOpen = true; + }} + /> +
+ +
streamingAutoScroll.handleScroll()} + > +
{@html streamingCodeHtml}
+
+
+ {/if} {/if}
- 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 new file mode 100644 index 000000000000..cada489ca97a --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -0,0 +1,756 @@ +.markdown-block--unstable { + display: contents; +} + +/* Streaming code block uses .code-block-wrapper styles */ +.streaming-code-block .streaming-code-pre { + background: transparent; + padding: 0.5rem; + margin: 0; + overflow-x: visible; + border-radius: 0; + border: none; + font-size: 0.875rem; +} + +/* Base typography styles */ +.markdown-content :global(p) { + margin-block: 1rem; + 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.5rem; +} + +/* Headers with consistent spacing */ +.markdown-content :global(h1) { + font-size: 1.875rem; + font-weight: 700; + line-height: 1.2; + margin: 1.5rem 0 0.75rem 0; +} + +.markdown-content :global(h2) { + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; + margin: 1.25rem 0 0.5rem 0; +} + +.markdown-content :global(h3) { + font-size: 1.25rem; + font-weight: 600; + margin: 1.5rem 0 0.5rem 0; + line-height: 1.4; +} + +.markdown-content :global(h4) { + font-size: 1.125rem; + font-weight: 600; + margin: 0.75rem 0 0.25rem 0; +} + +.markdown-content :global(h5) { + font-size: 1rem; + font-weight: 600; + margin: 0.5rem 0 0.25rem 0; +} + +.markdown-content :global(h6) { + font-size: 0.875rem; + font-weight: 600; + margin: 0.5rem 0 0.25rem 0; +} + +/* Text formatting */ +.markdown-content :global(strong) { + font-weight: 600; +} + +.markdown-content :global(em) { + font-style: italic; +} + +.markdown-content :global(del) { + text-decoration: line-through; + opacity: 0.7; +} + +/* Inline code */ +.markdown-content :global(code:not(pre code)) { + background: var(--muted); + color: var(--muted-foreground); + padding: 0.125rem 0.375rem; + border-radius: 0.375rem; + font-size: 0.875rem; +} + +.markdown-content :global(pre) { + display: inline; + margin: 0 !important; + overflow: hidden !important; + background: var(--muted); + overflow-x: auto; + border-radius: 1rem; + border: none; + line-height: 1 !important; +} + +.markdown-content :global(pre code) { + padding: 0 !important; + display: inline !important; +} + +.markdown-content :global(code) { + background: transparent; + color: var(--code-foreground); +} + +/* Links */ +.markdown-content :global(a) { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.2s ease; + overflow-wrap: anywhere; + word-break: break-all; +} + +.markdown-content :global(a:hover) { + color: var(--primary); +} + +/* Lists */ +.markdown-content :global(ul) { + list-style-type: disc; + margin-inline-start: 1.5rem; + margin-bottom: 1rem; +} + +.markdown-content :global(ol) { + list-style-type: decimal; + margin-inline-start: 1.5rem; + margin-bottom: 1rem; +} + +.markdown-content :global(li) { + margin-bottom: 0.25rem; + padding-inline-start: 0.5rem; +} + +.markdown-content :global(li::marker) { + color: var(--muted-foreground); +} + +/* Nested lists */ +.markdown-content :global(ul ul) { + list-style-type: circle; + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.markdown-content :global(ol ol) { + list-style-type: lower-alpha; + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +/* Task lists */ +.markdown-content :global(.task-list-item) { + list-style: none; + margin-inline-start: 0; + padding-inline-start: 0; +} + +.markdown-content :global(.task-list-item-checkbox) { + margin-right: 0.5rem; + margin-top: 0.125rem; +} + +/* Blockquotes */ +.markdown-content :global(blockquote) { + border-left: 4px solid var(--border); + padding: 0.5rem 1rem; + margin: 1.5rem 0; + font-style: italic; + color: var(--muted-foreground); + background: var(--muted); + border-radius: 0 0.375rem 0.375rem 0; +} + +/* Tables */ +.markdown-content :global(table) { + width: 100%; + margin: 1.5rem 0; + border-collapse: collapse; + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; +} + +.markdown-content :global(th) { + background: hsl(var(--muted) / 0.3); + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + text-align: left; + font-weight: 600; +} + +.markdown-content :global(td) { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; +} + +.markdown-content :global(tr:nth-child(even)) { + background: hsl(var(--muted) / 0.1); +} + +/* User message markdown should keep table borders visible on light primary backgrounds */ +div.markdown-user-content :global(table), +div.markdown-user-content :global(th), +div.markdown-user-content :global(td), +div.markdown-user-content :global(.table-wrapper) { + border-color: currentColor; +} + +/* Horizontal rules */ +.markdown-content :global(hr) { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +/* Images */ +.markdown-content :global(img) { + border-radius: 0.5rem; + box-shadow: + 0 1px 3px 0 rgb(0 0 0 / 0.1), + 0 1px 2px -1px rgb(0 0 0 / 0.1); + margin: 1.5rem 0; + max-width: 100%; + height: auto; +} + +/* Code blocks */ + +.markdown-content :global(.code-block-wrapper) { + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid color-mix(in oklch, var(--border) 30%, transparent); + background: var(--code-background); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + min-height: var(--min-message-height); + max-height: var(--max-message-height); +} + +.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) { + margin-top: 1rem; +} + +.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) { + margin-bottom: 1rem; +} + +.markdown-content:global(.dark) :global(.code-block-wrapper) { + border-color: color-mix(in oklch, var(--border) 20%, transparent); +} + +/* Scroll container for code blocks (both streaming and completed) */ +.markdown-content :global(.code-block-scroll-container), +.streaming-code-scroll-container { + min-height: var(--min-message-height); + max-height: var(--max-message-height); + overflow-y: auto; + overflow-x: auto; + padding: 3rem 1rem 1rem; + line-height: 1.3; +} + +.full-height-code-blocks :global(.code-block-wrapper) { + max-height: none; +} + +.full-height-code-blocks :global(.code-block-scroll-container), +.full-height-code-blocks .streaming-code-scroll-container { + max-height: none; + overflow-y: visible; +} + +.markdown-content :global(.code-block-header) { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem 0; + font-size: 0.875rem; + position: absolute; + top: 0; + left: 0; + right: 0; +} + +.markdown-content :global(.code-language) { + color: var(--color-foreground); + font-weight: 500; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Liberation Mono', Menlo, monospace; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.markdown-content :global(.code-block-actions) { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.markdown-content :global(.copy-code-btn), +.markdown-content :global(.preview-code-btn), +.markdown-content :global(.toggle-source-btn) { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + color: var(--code-foreground); + cursor: pointer; + transition: all 0.2s ease; +} + +.markdown-content :global(.copy-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(.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; + border-radius: 0; + border: none; + font-size: 0.875rem; +} + +/* Mentions and hashtags */ +.markdown-content :global(.mention) { + color: hsl(var(--primary)); + font-weight: 500; + text-decoration: none; +} + +.markdown-content :global(.mention:hover) { + text-decoration: underline; +} + +.markdown-content :global(.hashtag) { + color: hsl(var(--primary)); + font-weight: 500; + text-decoration: none; +} + +.markdown-content :global(.hashtag:hover) { + text-decoration: underline; +} + +/* Advanced table enhancements */ +.markdown-content :global(table) { + transition: all 0.2s ease; +} + +.markdown-content :global(table:hover) { + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); +} + +.markdown-content :global(th:hover), +.markdown-content :global(td:hover) { + background: var(--muted); +} + +/* Disable hover effects when rendering user messages */ +.markdown-user-content :global(a), +.markdown-user-content :global(a:hover) { + color: inherit; +} + +.markdown-user-content :global(table:hover) { + box-shadow: none; +} + +.markdown-user-content :global(th:hover), +.markdown-user-content :global(td:hover) { + background: inherit; +} + +/* Enhanced blockquotes */ +.markdown-content :global(blockquote) { + transition: all 0.2s ease; + position: relative; +} + +.markdown-content :global(blockquote:hover) { + border-left-width: 6px; + background: var(--muted); + transform: translateX(2px); +} + +.markdown-content :global(blockquote::before) { + content: '"'; + position: absolute; + top: -0.5rem; + left: 0.5rem; + font-size: 3rem; + color: var(--muted-foreground); + font-family: serif; + line-height: 1; +} + +/* Enhanced images */ +.markdown-content :global(img) { + transition: all 0.3s ease; + cursor: pointer; +} + +.markdown-content :global(img:hover) { + transform: scale(1.02); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +/* Image zoom overlay */ +.markdown-content :global(.image-zoom-overlay) { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + cursor: pointer; +} + +.markdown-content :global(.image-zoom-overlay img) { + max-width: 90vw; + max-height: 90vh; + border-radius: 0.5rem; + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); +} + +/* Enhanced horizontal rules */ +.markdown-content :global(hr) { + border: none; + height: 2px; + background: linear-gradient(to right, transparent, var(--border), transparent); + margin: 2rem 0; + position: relative; +} + +.markdown-content :global(hr::after) { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 1rem; + height: 1rem; + background: var(--border); + border-radius: 50%; +} + +/* Scrollable tables */ +.markdown-content :global(.table-wrapper) { + overflow-x: auto; + margin: 1.5rem 0; + border-radius: 0.5rem; + border: 1px solid var(--border); +} + +.markdown-content :global(.table-wrapper table) { + margin: 0; + border: none; +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .markdown-content :global(h1) { + font-size: 1.5rem; + } + + .markdown-content :global(h2) { + font-size: 1.25rem; + } + + .markdown-content :global(h3) { + font-size: 1.125rem; + } + + .markdown-content :global(table) { + font-size: 0.875rem; + } + + .markdown-content :global(th), + .markdown-content :global(td) { + padding: 0.375rem 0.5rem; + } + + .markdown-content :global(.table-wrapper) { + margin: 0.5rem -1rem; + border-radius: 0; + border-left: none; + border-right: none; + } +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .markdown-content :global(blockquote:hover) { + background: var(--muted); + } +} + +/* Image load error fallback */ +.markdown-content :global(.image-load-error) { + display: flex; + align-items: center; + justify-content: center; + margin: 1.5rem 0; + padding: 1.5rem; + border-radius: 0.5rem; + background: var(--muted); + border: 1px dashed var(--border); +} + +.markdown-content :global(.image-error-content) { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + color: var(--muted-foreground); + text-align: center; +} + +.markdown-content :global(.image-error-content svg) { + opacity: 0.5; +} + +.markdown-content :global(.image-error-text) { + font-size: 0.875rem; +} + +.markdown-content :global(.image-error-link) { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--primary); + background: var(--background); + border: 1px solid var(--border); + border-radius: 0.375rem; + text-decoration: none; + transition: all 0.2s ease; +} + +.markdown-content :global(.image-error-link:hover) { + background: var(--muted); + border-color: var(--primary); +} + +/* 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; + text-align: center; + font-family: inherit; + cursor: pointer; + transition: opacity 0.15s ease; + 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; +} + +/* 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; +} + +.markdown-content :global(pre.mermaid svg) { + max-width: 90%; + margin: 0 auto; + height: auto; + display: block; + padding: 3rem 1rem; +} + +/* 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; + border: 1px solid color-mix(in oklch, var(--border) 30%, transparent); + background: var(--code-background); + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + position: relative; + min-height: var(--min-message-height); + max-height: var(--max-message-height); +} + +.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(.svg-scroll-container) { + min-height: 350px; + max-height: var(--max-message-height); + overflow-y: auto; + overflow-x: auto; + display: flex; + 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(.svg-block-wrapper) { + max-height: none; +} + +.full-height-code-blocks :global(.mermaid-scroll-container), +.full-height-code-blocks :global(.svg-scroll-container) { + max-height: none; + overflow-y: visible; +} + +/* 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; + padding: 0.5rem 1rem 0; + font-size: 0.875rem; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; +} + +.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; +} + +/* 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; + margin: 0; + text-align: center; +} + +.markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) { + width: unset !important; + height: auto; + display: block; + 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; + display: flex; + align-items: center; + justify-content: center; +} + +.mermaid-loading-placeholder { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--muted-foreground); +} + +.mermaid-loading-text { + font-size: 0.875rem; + font-style: italic; +} 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 new file mode 100644 index 000000000000..0a1db1909377 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts @@ -0,0 +1,305 @@ +/** + * Event handler factories for markdown content components. + * Uses dependency injection pattern to avoid direct component state access. + */ + +import { + CODE_BLOCK_CLASS, + MARKDOWN_DATA_ATTRS, + MERMAID_BLOCK_CLASS, + MERMAID_SYNTAX_ATTR, + MERMAID_WRAPPER_CLASS +} from '$lib/constants'; +import { BooleanString } from '$lib/enums'; +import { copyCodeToClipboard, copyToClipboard } from '$lib/utils'; + +export interface PreviewState { + previewDialogOpen: boolean; + previewCode: string; + previewLanguage: string; + setPreviewDialogOpen: (open: boolean) => void; + setPreviewCode: (code: string) => void; + setPreviewLanguage: (lang: string) => void; +} + +export interface MermaidPreviewState { + mermaidPreviewOpen: boolean; + mermaidPreviewSvgHtml: string; + setMermaidPreviewOpen: (open: boolean) => void; + setMermaidPreviewSvgHtml: (html: string) => void; +} + +export interface RenderedBlocksState { + renderedBlocks: Array<{ id: string; html: string; contentHash?: string }>; + setRenderedBlocks: (blocks: Array<{ id: string; html: string; contentHash?: string }>) => void; +} + +/** + * Creates a click handler for copy buttons in code blocks. + * Copies the code content to clipboard. + */ +export function createHandleCopyClick() { + return async function handleCopyClick(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget as HTMLButtonElement | null; + + if (!target) return; + + const wrapper = target.closest(`.${CODE_BLOCK_CLASS.WRAPPER}`); + + if (!wrapper) return; + + const codeElement = wrapper.querySelector(`code[${MARKDOWN_DATA_ATTRS.CODE_ID}]`); + + if (!codeElement) return; + + const rawCode = codeElement.textContent ?? ''; + + try { + await copyCodeToClipboard(rawCode); + } catch (error) { + console.error('Failed to copy code:', error); + } + }; +} + +/** + * Creates a handler for preview dialog open state changes. + * Clears preview content when dialog is closed. + */ +export function createHandlePreviewDialogOpenChange(previewState: PreviewState) { + return function handlePreviewDialogOpenChange(open: boolean) { + previewState.setPreviewDialogOpen(open); + + if (!open) { + previewState.setPreviewCode(''); + previewState.setPreviewLanguage('text'); + } + }; +} + +/** + * Creates a click handler for preview buttons within HTML code blocks. + * Opens a preview dialog with the rendered HTML content. + */ +export function createHandlePreviewClick(previewState: PreviewState) { + return async function handlePreviewClick(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget as HTMLButtonElement | null; + + if (!target) return; + + const wrapper = target.closest(`.${CODE_BLOCK_CLASS.WRAPPER}`); + + if (!wrapper) return; + + const codeElement = wrapper.querySelector(`code[${MARKDOWN_DATA_ATTRS.CODE_ID}]`); + + if (!codeElement) return; + + const rawCode = codeElement.textContent ?? ''; + const languageLabel = wrapper.querySelector(`.${CODE_BLOCK_CLASS.LANGUAGE}`); + const language = languageLabel?.textContent?.trim() || 'text'; + + previewState.setPreviewCode(rawCode); + previewState.setPreviewLanguage(language); + previewState.setPreviewDialogOpen(true); + }; +} + +/** + * Creates a click handler for mermaid block interactions. + * Handles copy, preview, and diagram click events via event delegation. + */ +export function createHandleMermaidClick(mermaidState: MermaidPreviewState) { + return async function handleMermaidClick(event: MouseEvent) { + const target = event.target as HTMLElement; + // Check if clicking on copy or preview button in mermaid block + const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .${CODE_BLOCK_CLASS.COPY_BTN}`); + const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .${CODE_BLOCK_CLASS.PREVIEW_BTN}`); + + if (copyBtn || previewBtn) { + const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`); + + if (!wrapper) return; + + const preElement = wrapper.querySelector( + `pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]` + ); + + if (!preElement) return; + + const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? ''; + + if (copyBtn) { + event.preventDefault(); + event.stopPropagation(); + try { + await copyToClipboard(mermaidSyntax); + } catch (error) { + console.error('Failed to copy mermaid syntax:', error); + } + + return; + } + + if (previewBtn) { + event.preventDefault(); + event.stopPropagation(); + const svg = preElement.querySelector('svg'); + + if (!svg) return; + + mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML); + mermaidState.setMermaidPreviewOpen(true); + + return; + } + } + + // Otherwise, open preview when clicking on the mermaid diagram itself + const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`); + + if (!mermaidEl) return; + + const svg = mermaidEl.querySelector('svg'); + + if (!svg) return; + + mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML); + mermaidState.setMermaidPreviewOpen(true); + }; +} + +/** + * Creates a handler for mermaid preview dialog open state changes. + * Cleans up SVG content when dialog is closed. + */ +export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPreviewState) { + return function handleMermaidPreviewOpenChange(open: boolean) { + mermaidState.setMermaidPreviewOpen(open); + + if (!open) { + mermaidState.setMermaidPreviewSvgHtml(''); + } + }; +} + +/** + * Creates an error handler for images that fail to load (e.g., CORS issues). + * Shows fallback UI for broken images. + */ +export function createHandleImageError( + renderedBlocksState: RenderedBlocksState, + IMAGE_NOT_ERROR_BOUND_SELECTOR: string, + errorBoundAttr: string, + booleanString: BooleanString +) { + return async function handleImageError(event: Event) { + const img = event.target as HTMLImageElement; + + if (!img) return; + + const blockId = img + .closest(`[${MARKDOWN_DATA_ATTRS.BLOCK_ID}]`) + ?.getAttribute(MARKDOWN_DATA_ATTRS.BLOCK_ID); + + if (!blockId) return; + + const block = renderedBlocksState.renderedBlocks.find((b) => b.id === blockId); + + if (!block) return; + + // Skip if already handled + if (img.getAttribute(errorBoundAttr) === booleanString) return; + + img.setAttribute(errorBoundAttr, booleanString); + + // Get the fallback HTML and replace the image + const fallbackHtml = `
+ ⚠️ + Failed to load image +
`; + // Replace the img element with fallback in the block's HTML + const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => { + if (src === img.src) { + return fallbackHtml.replace( + `${MARKDOWN_DATA_ATTRS.ORIGINAL_SRC}=""`, + `${MARKDOWN_DATA_ATTRS.ORIGINAL_SRC}="${src}"` + ); + } + + return match; + }); + // Update the block + const newBlocks = renderedBlocksState.renderedBlocks.map((b) => + b.id === blockId ? { ...b, html: newHtml } : b + ); + + renderedBlocksState.setRenderedBlocks(newBlocks); + }; +} + +/** + * Creates a function to set up code block action event listeners. + * Binds click handlers to copy and preview buttons within code blocks. + */ +export function createSetupCodeBlockActions( + handleCopyClick: (event: Event) => void, + handlePreviewClick: (event: Event) => void +) { + return function setupCodeBlockActions(containerRef: HTMLElement | null) { + if (!containerRef) return; + + const wrappers = containerRef.querySelectorAll(`.${CODE_BLOCK_CLASS.WRAPPER}`); + + for (const wrapper of wrappers) { + const copyButton = wrapper.querySelector(`.${CODE_BLOCK_CLASS.COPY_BTN}`); + const previewButton = wrapper.querySelector( + `.${CODE_BLOCK_CLASS.PREVIEW_BTN}` + ); + + if ( + copyButton && + copyButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE + ) { + copyButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE); + copyButton.addEventListener('click', handleCopyClick); + } + + if ( + previewButton && + previewButton.getAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND) !== BooleanString.TRUE + ) { + previewButton.setAttribute(MARKDOWN_DATA_ATTRS.LISTENER_BOUND, BooleanString.TRUE); + previewButton.addEventListener('click', handlePreviewClick); + } + } + }; +} + +/** + * Creates a function to set up image error handlers. + * Attaches error handlers to images to show fallback UI when loading fails. + */ +export function createSetupImageErrorHandlers( + handleImageError: (event: Event) => void, + IMAGE_NOT_ERROR_BOUND_SELECTOR: string, + errorBoundAttr: string, + booleanString: BooleanString +) { + return function setupImageErrorHandlers(containerRef: HTMLElement | null) { + if (!containerRef) return; + + const images = containerRef.querySelectorAll(IMAGE_NOT_ERROR_BOUND_SELECTOR); + + for (const img of images) { + img.setAttribute(errorBoundAttr, booleanString); + img.addEventListener('error', handleImageError); + } + }; +} diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts new file mode 100644 index 000000000000..9e2c0f4f8cc6 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-utils.ts @@ -0,0 +1,86 @@ +/** + * Utility functions for markdown processing in MarkdownContent component. + */ + +import { MARKDOWN_DATA_ATTRS } from '$lib/constants'; +import type { RootContent as HastRootContent } from 'hast'; + +/** + * Generates a unique identifier for a HAST node based on its position. + * Used for stable block identification during incremental rendering. + * @param node - The HAST root content node + * @param indexFallback - Fallback index if position is unavailable + * @returns Unique string identifier for the node + */ +export function getHastNodeId(node: HastRootContent, indexFallback: number): string { + const position = node.position; + + if (position?.start?.offset != null && position?.end?.offset != null) { + return `hast-${position.start.offset}-${position.end.offset}`; + } + + return `${node.type}-${indexFallback}`; +} + +/** + * Generates a hash for MDAST node based on its position. + * Used for cache lookup during incremental rendering. + */ +export function getMdastNodeHash(node: unknown, index: number): string { + const n = node as { + type?: string; + position?: { start?: { offset?: number }; end?: { offset?: number } }; + }; + + if (n.position?.start?.offset != null && n.position?.end?.offset != null) { + return `${n.type}-${n.position.start.offset}-${n.position.end.offset}`; + } + + return `${n.type}-idx${index}`; +} + +/** + * Determines if the new content is an append (new content added to existing blocks). + * This is used to optimize cache reuse during streaming updates. + * + * @param newContent - The new markdown content + * @param previousContent - The previous markdown content to check against + * @returns true if the content appears to be an append operation + */ +export function isAppendMode(newContent: string, previousContent: string): boolean { + return previousContent.length > 0 && newContent.startsWith(previousContent); +} + +export interface CodeInfo { + rawCode: string; + language: string; +} + +/** + * Extracts code information from a button click target within a code block. + * @param target - The clicked button element + * @returns Object with rawCode and language, or null if extraction fails + */ +export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null { + const wrapper = target.closest('.code-block-wrapper'); + + if (!wrapper) { + console.error('No wrapper found'); + + return null; + } + + const codeElement = wrapper.querySelector(`code[${MARKDOWN_DATA_ATTRS.CODE_ID}]`); + + if (!codeElement) { + console.error('No code element found in wrapper'); + + return null; + } + + const rawCode = codeElement.textContent ?? ''; + const languageLabel = wrapper.querySelector('.code-language'); + const language = languageLabel?.textContent?.trim() || 'text'; + + return { language, rawCode }; +} 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 new file mode 100644 index 000000000000..4eb38e49fd01 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts @@ -0,0 +1,205 @@ +/** + * Shared utilities for enhanced code blocks and mermaid diagram blocks. + * Contains common HAST element creation functions to avoid code duplication. + */ + +import { + CODE_BLOCK_CLASS, + CODE_ICON_SVG, + COPY_ICON_SVG, + DIAGRAM_SOURCE_CLASS, + PREVIEW_ICON_SVG, + TOGGLE_SOURCE_BTN_CLASS +} from '$lib/constants'; +import type { Element, ElementContent } from 'hast'; + +export interface BlockIdGenerator { + (id: number): string; +} + +/** + * Creates an icon element with the given SVG content. + */ +export function createIconElement(svg: string): Element { + return { + children: [{ type: 'raw', value: svg } as unknown as ElementContent], + properties: {}, + tagName: 'span', + type: 'element' + }; +} + +/** + * 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, + extraProperties: Record = {} +): Element { + return { + children: [createIconElement(iconSvg)], + properties: { + className: [className], + [idAttribute]: id, + title, + type: 'button', + ...extraProperties + }, + tagName: 'button', + type: 'element' + }; +} + +/** + * Creates a copy button element. + */ +export function createCopyButton(id: string, idAttribute: string, title: string = 'Copy'): Element { + return createButton(CODE_BLOCK_CLASS.COPY_BTN, title, COPY_ICON_SVG, id, idAttribute); +} + +/** + * Creates a preview button element. + */ +export function createPreviewButton( + id: string, + idAttribute: string, + title: string = 'Preview' +): Element { + return createButton(CODE_BLOCK_CLASS.PREVIEW_BTN, 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 ?? { + children: [{ type: 'text', value: source }], + properties: { className: ['hljs', `language-${language}`] }, + tagName: 'code', + type: 'element' + }; + + return { + children: [ + { + children: [code], + properties: {}, + tagName: 'pre', + type: 'element' + } + ], + properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_CLASS.SCROLL_CONTAINER] }, + tagName: 'div', + type: 'element' + }; +} + +/** + * Creates a block header with language label and action buttons. + */ +export function createBlockHeader( + language: string, + id: string, + idAttribute: string, + actions: Element[], + languageClassName: string = CODE_BLOCK_CLASS.LANGUAGE +): Element { + return { + children: [ + { + children: [{ type: 'text', value: language }], + properties: { className: [languageClassName] }, + tagName: 'span', + type: 'element' + }, + { + children: actions, + properties: { className: [CODE_BLOCK_CLASS.ACTIONS] }, + tagName: 'div', + type: 'element' + } + ], + properties: { className: [CODE_BLOCK_CLASS.HEADER] }, + tagName: 'div', + type: 'element' + }; +} + +/** + * Creates a scroll container element. + */ +export function createScrollContainer(preElement: Element, scrollContainerClass: string): Element { + return { + children: [preElement], + properties: { className: [scrollContainerClass] }, + tagName: 'div', + type: 'element' + }; +} + +/** + * 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, + extraChildren: Element[] = [] +): Element { + return { + children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren], + properties: { + className: [wrapperClass, CODE_BLOCK_CLASS.RELATIVE], + ...additionalAttributes + } as Element['properties'], + tagName: 'div', + type: 'element' + }; +} + +/** + * Generates a unique block ID using a global counter. + */ +export function generateBlockId(prefix: string, windowKey: keyof Window): string { + if (typeof window !== 'undefined') { + const idx = window[windowKey] as number | undefined; + const next = (idx ?? 0) + 1; + + (window as unknown as Record)[windowKey] = next; + + return `${prefix}-${next}`; + } + + // Fallback for SSR - use timestamp + random + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts index 9d9348a5f0b0..f42ab1c07cae 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-code-blocks.ts @@ -10,19 +10,17 @@ * avoiding the need to stringify and re-parse HTML. */ +import { + createBlockHeader, + createCopyButton, + createPreviewButton, + createWrapper, + generateBlockId +} from './code-block-utils'; +import { CODE_BLOCK_CLASS, MARKDOWN_DATA_ATTRS } from '$lib/constants'; +import type { Element, ElementContent, Root } from 'hast'; import type { Plugin } from 'unified'; -import type { Root, Element, ElementContent } from 'hast'; import { visit } from 'unist-util-visit'; -import { - CODE_BLOCK_SCROLL_CONTAINER_CLASS, - CODE_BLOCK_WRAPPER_CLASS, - CODE_BLOCK_HEADER_CLASS, - CODE_BLOCK_ACTIONS_CLASS, - CODE_LANGUAGE_CLASS, - COPY_CODE_BTN_CLASS, - PREVIEW_CODE_BTN_CLASS, - RELATIVE_CLASS -} from '$lib/constants'; declare global { interface Window { @@ -30,89 +28,9 @@ declare global { } } -const COPY_ICON_SVG = ``; - -const PREVIEW_ICON_SVG = ``; - -function createIconElement(svg: string): Element { - return { - type: 'element', - tagName: 'span', - properties: {}, - children: [{ type: 'raw', value: svg } as unknown as ElementContent] - }; -} - -function createButton(className: string, title: string, iconSvg: string, codeId: string): Element { - return { - type: 'element', - tagName: 'button', - properties: { - className: [className], - 'data-code-id': codeId, - title, - type: 'button' - }, - children: [createIconElement(iconSvg)] - }; -} - -function createCopyButton(codeId: string): Element { - return createButton(COPY_CODE_BTN_CLASS, 'Copy code', COPY_ICON_SVG, codeId); -} - -function createPreviewButton(codeId: string): Element { - return createButton(PREVIEW_CODE_BTN_CLASS, 'Preview code', PREVIEW_ICON_SVG, codeId); -} - -function createHeader(language: string, codeId: string): Element { - const actions: Element[] = [createCopyButton(codeId)]; - - if (language.toLowerCase() === 'html') { - actions.push(createPreviewButton(codeId)); - } - - return { - type: 'element', - tagName: 'div', - properties: { className: [CODE_BLOCK_HEADER_CLASS] }, - children: [ - { - type: 'element', - tagName: 'span', - properties: { className: [CODE_LANGUAGE_CLASS] }, - children: [{ type: 'text', value: language }] - }, - { - type: 'element', - tagName: 'div', - properties: { className: [CODE_BLOCK_ACTIONS_CLASS] }, - children: actions - } - ] - }; -} - -function createScrollContainer(preElement: Element): Element { - return { - type: 'element', - tagName: 'div', - properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] }, - children: [preElement] - }; -} - -function createWrapper(header: Element, preElement: Element): Element { - return { - type: 'element', - tagName: 'div', - properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] }, - children: [header, createScrollContainer(preElement)] - }; -} - function extractLanguage(codeElement: Element): string { const className = codeElement.properties?.className; + if (!Array.isArray(className)) return 'text'; for (const cls of className) { @@ -124,17 +42,6 @@ function extractLanguage(codeElement: Element): string { return 'text'; } -/** - * Generates a unique code block ID using a global counter. - */ -function generateCodeId(): string { - if (typeof window !== 'undefined') { - return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`; - } - // Fallback for SSR - use timestamp + random - return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; -} - /** * Rehype plugin to enhance code blocks with wrapper, header, and action buttons. * This plugin wraps
 elements with a container that includes:
@@ -154,15 +61,28 @@ export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
 			if (!codeElement) return;
 
 			const language = extractLanguage(codeElement);
-			const codeId = generateCodeId();
+			const codeId = generateBlockId('code', 'idxCodeBlock');
 
 			codeElement.properties = {
 				...codeElement.properties,
-				'data-code-id': codeId
+				[MARKDOWN_DATA_ATTRS.CODE_ID]: codeId
 			};
 
-			const header = createHeader(language, codeId);
-			const wrapper = createWrapper(header, node);
+			const actions: Element[] = [
+				createCopyButton(codeId, MARKDOWN_DATA_ATTRS.CODE_ID, 'Copy code')
+			];
+
+			if (language.toLowerCase() === 'html') {
+				actions.push(createPreviewButton(codeId, MARKDOWN_DATA_ATTRS.CODE_ID, 'Preview code'));
+			}
+
+			const header = createBlockHeader(language, codeId, MARKDOWN_DATA_ATTRS.CODE_ID, actions);
+			const wrapper = createWrapper(
+				header,
+				node,
+				CODE_BLOCK_CLASS.WRAPPER,
+				CODE_BLOCK_CLASS.SCROLL_CONTAINER
+			);
 
 			// 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/enhance-links.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
index b5fbcbdaae73..880a10cad6ed 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-links.ts
@@ -5,8 +5,8 @@
  * ensuring external links open in new tabs safely.
  */
 
+import type { Element, Root } from 'hast';
 import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
 import { visit } from 'unist-util-visit';
 
 /**
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
new file mode 100644
index 000000000000..d1f85b7166e2
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts
@@ -0,0 +1,106 @@
+/**
+ * Rehype plugin to enhance mermaid diagram blocks with wrapper, header, and action buttons.
+ *
+ * Wraps 
 elements with a container that includes:
+ * - Language label ("mermaid")
+ * - Copy button (copies mermaid syntax to clipboard)
+ * - Preview button (opens fullscreen preview dialog)
+ *
+ * This operates directly on the HAST tree for better performance,
+ * avoiding the need to stringify and re-parse HTML.
+ */
+
+import {
+	createBlockHeader,
+	createCopyButton,
+	createPreviewButton,
+	createSourceView,
+	createToggleSourceButton,
+	createWrapper,
+	generateBlockId
+} from './code-block-utils';
+import type { DiagramPreData } from './pre-transform';
+import {
+	DIAGRAM_VIEW_MODE_ATTR,
+	DIAGRAM_VIEW_RENDERED,
+	MERMAID_BLOCK_CLASS,
+	MERMAID_ID_ATTR,
+	MERMAID_LANGUAGE,
+	MERMAID_SCROLL_CONTAINER_CLASS,
+	MERMAID_SYNTAX_ATTR,
+	MERMAID_WRAPPER_CLASS
+} from '$lib/constants';
+import type { Element, ElementContent, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+declare global {
+	interface Window {
+		idxMermaidBlock?: number;
+	}
+}
+
+/**
+ * Rehype plugin to enhance mermaid diagram blocks with wrapper, header, and action buttons.
+ * This plugin wraps 
 elements with a container that includes:
+ * - Language label ("mermaid")
+ * - Copy button
+ * - Preview button
+ */
+export const rehypeEnhanceMermaidBlocks: 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 isMermaid = className.some(
+				(cls) => typeof cls === 'string' && cls === MERMAID_BLOCK_CLASS
+			);
+
+			if (!isMermaid) return;
+
+			const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock');
+			// Extract the mermaid syntax (text content of the pre element)
+			const diagramText = node.children
+				.map((child) => {
+					if (child.type === 'text') return child.value;
+
+					return '';
+				})
+				.join('');
+
+			// Store the mermaid syntax in data attribute for copy functionality
+			node.properties = {
+				...node.properties,
+				[MERMAID_ID_ATTR]: mermaidId,
+				[MERMAID_SYNTAX_ATTR]: diagramText
+			};
+
+			const actions = [
+				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_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,
+				{
+					[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
+					[MERMAID_ID_ATTR]: mermaidId
+				},
+				[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/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..e1ec4898e3f9
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts
@@ -0,0 +1,87 @@
+/**
+ * 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 {
+	createBlockHeader,
+	createCopyButton,
+	createPreviewButton,
+	createSourceView,
+	createToggleSourceButton,
+	createWrapper,
+	generateBlockId
+} from './code-block-utils';
+import type { DiagramPreData } from './pre-transform';
+import { DIAGRAM_VIEW_MODE_ATTR, DIAGRAM_VIEW_RENDERED, SVG } from '$lib/constants';
+import type { Element, ElementContent, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+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.ID_ATTR]: svgId,
+				[SVG.SOURCE_ATTR]: svgSource
+			};
+
+			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,
+				{
+					[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
+					[SVG.ID_ATTR]: svgId
+				},
+				[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/file-badge.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts
new file mode 100644
index 000000000000..ab50d437caa8
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts
@@ -0,0 +1,101 @@
+/**
+ * Rehype plugin that rewrites `file://` markdown anchors into the inline
+ * mention chip, sharing the class string with the ChatFormInputRich
+ * tokenizer via `$lib/constants`.
+ *
+ * The chip is presentational: `file://` navigation is blocked from
+ * http(s) pages, so the anchor becomes a plain `` (no link role,
+ * no tab stop); the full path stays available on `title`.
+ */
+
+import {
+	FILE_URI_PREFIX,
+	MENTION_BADGE_CLASSNAME,
+	MENTION_BADGE_ICON_CLASSNAME,
+	MENTION_BADGE_SVG_ATTRIBUTES,
+	PATH_SEPARATOR,
+	SETTINGS_KEYS
+} from '$lib/constants';
+import { settingsStore, toolsStore } from '$lib/stores';
+import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
+import type { Element, Root } from 'hast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+// Trailing path separators mark a directory and are kept out of the label.
+const TRAILING_SEPARATOR_REGEX = /\/+$/;
+
+function decodeHrefPath(href: string): string {
+	const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
+
+	return decodeFileLinkPath(stripped);
+}
+
+function labelFromFileUrl(href: string): string {
+	const decoded = decodeHrefPath(href);
+	const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
+	const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
+
+	return slash === -1 ? trimmed : trimmed.slice(slash + 1);
+}
+
+// A trailing `/` in the target marks a directory and selects the folder
+// icon, matching the convention the mention picker inserts with.
+function iconElement(href: string): Element {
+	return {
+		children: getMentionBadgeIconPaths(href).map((d) => ({
+			children: [],
+			properties: { d },
+			tagName: 'path',
+			type: 'element'
+		})),
+		properties: {
+			...MENTION_BADGE_SVG_ATTRIBUTES,
+			className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
+		},
+		tagName: 'svg',
+		type: 'element'
+	};
+}
+
+export const rehypeFileBadge: Plugin<[], Root> = () => {
+	return (tree: Root) => {
+		visit(tree, 'element', (node: Element) => {
+			if (node.tagName !== 'a') return;
+
+			const props = node.properties ?? {};
+			const href = typeof props.href === 'string' ? props.href : null;
+
+			if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
+
+			const label = labelFromFileUrl(href);
+			const titleAttr = typeof props.title === 'string' ? props.title : href;
+			const decodedPath = decodeHrefPath(href);
+
+			node.tagName = 'span';
+			node.properties = {
+				className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
+				title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
+			};
+			node.children = [
+				iconElement(href),
+				{
+					children: [
+						{
+							type: 'text',
+							value: getMentionBadgeLabel(
+								label,
+								decodedPath,
+								settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
+								toolsStore.serverHome
+							)
+						}
+					],
+					properties: { className: ['shrink-0', 'truncate'] },
+					tagName: 'span',
+					type: 'element'
+				}
+			];
+		});
+	};
+};
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
new file mode 100644
index 000000000000..61322f045cf1
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
@@ -0,0 +1,7 @@
+import { createPreTransform } from './pre-transform';
+import { MERMAID_BLOCK_CLASS, MERMAID_LANGUAGE } from '$lib/constants';
+
+/**
+ * Converts mermaid code blocks to 
 for client-side rendering.
+ */
+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..755fa1ecdb88
--- /dev/null
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts
@@ -0,0 +1,94 @@
+import type { Element, ElementContent, Root, Text } from 'hast';
+import type { Plugin } from 'unified';
+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 = {
+					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,
+					properties: {
+						className: [targetClass]
+					},
+					tagName: 'pre',
+					type: 'element'
+				};
+
+				(parent.children as ElementContent[])[index] = pre;
+			});
+		};
+	};
+}
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
index 0a8b93ad5472..b63dddbdbe3b 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/rehype-rtl-support.ts
@@ -6,8 +6,8 @@
  * (including those not in a predefined list) receive the attribute.
  */
 
+import type { Element, Root } from 'hast';
 import type { Plugin } from 'unified';
-import type { Root, Element } from 'hast';
 import { visit } from 'unist-util-visit';
 
 /**
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
index 36e7a3192bc1..5d3ade0f1486 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/resolve-attachment-images.ts
@@ -1,7 +1,7 @@
+import { AttachmentType, UrlProtocol } from '$lib/enums';
+import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
 import type { Root as HastRoot } from 'hast';
 import { visit } from 'unist-util-visit';
-import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
-import { AttachmentType, UrlProtocol } from '$lib/enums';
 
 /**
  * Rehype plugin to resolve attachment image sources.
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..7baa95ca4180
--- /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 } 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/MarkdownContent/plugins/rehype/table-html-restorer.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
index bc5d034653f2..1dd0247fea17 100644
--- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
+++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/table-html-restorer.ts
@@ -64,27 +64,30 @@
  * // With this plugin: 
becomes line break,
    becomes actual list */ -import type { Plugin } from 'unified'; +import { BR_PATTERN, LI_PATTERN, LIST_PATTERN } from '$lib/constants'; import type { Element, ElementContent, Root, Text } from 'hast'; +import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; import { visitParents } from 'unist-util-visit-parents'; -import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants'; /** * Expands text containing `
    ` tags into an array of text nodes and br elements. */ function expandBrTags(value: string): ElementContent[] { const matches = [...value.matchAll(BR_PATTERN)]; + if (!matches.length) return [{ type: 'text', value } as Text]; const result: ElementContent[] = []; + let cursor = 0; for (const m of matches) { if (m.index! > cursor) { result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text); } - result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element); + + result.push({ children: [], properties: {}, tagName: 'br', type: 'element' } as Element); cursor = m.index! + m[0].length; } @@ -101,10 +104,12 @@ function expandBrTags(value: string): ElementContent[] { */ function parseList(value: string): Element | null { const match = value.trim().match(LIST_PATTERN); + if (!match) return null; const body = match[1]; const items: ElementContent[] = []; + let cursor = 0; for (const liMatch of body.matchAll(LI_PATTERN)) { @@ -112,10 +117,10 @@ function parseList(value: string): Element | null { if (body.slice(cursor, liMatch.index!).trim()) return null; items.push({ - type: 'element', - tagName: 'li', + children: expandBrTags(liMatch[1] ?? ''), properties: {}, - children: expandBrTags(liMatch[1] ?? '') + tagName: 'li', + type: 'element' } as Element); cursor = liMatch.index! + liMatch[0].length; @@ -124,7 +129,7 @@ function parseList(value: string): Element | null { // Reject if no items found or trailing garbage exists if (!items.length || body.slice(cursor).trim()) return null; - return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element; + return { children: items, properties: {}, tagName: 'ul', type: 'element' } as Element; } /** @@ -133,11 +138,13 @@ function parseList(value: string): Element | null { function processCell(cell: Element) { visitParents(cell, 'text', (textNode: Text, ancestors) => { const parent = ancestors[ancestors.length - 1]; + if (!parent || parent.type !== 'element') return; const parentEl = parent as Element; const siblings = parentEl.children as ElementContent[]; const startIndex = siblings.indexOf(textNode as ElementContent); + if (startIndex === -1) return; // Combine consecutive text nodes and
    elements into one string @@ -146,6 +153,7 @@ function processCell(cell: Element) { for (let i = startIndex; i < siblings.length; i++) { const sib = siblings[i]; + if (sib.type === 'text') { combined += (sib as Text).value; endIndex = i; @@ -159,13 +167,16 @@ function processCell(cell: Element) { // Try parsing as list first (replaces entire combined range) const list = parseList(combined); + if (list) { siblings.splice(startIndex, endIndex - startIndex + 1, list); + return; } // Otherwise, just expand
    tags in this text node const expanded = expandBrTags(textNode.value); + if (expanded.length !== 1 || expanded[0] !== textNode) { siblings.splice(startIndex, 1, ...expanded); } diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts index c974d8b1893e..5183fe531042 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/remark/literal-html.ts @@ -1,7 +1,7 @@ +import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants'; +import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast'; import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; -import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast'; -import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants'; /** * remark plugin that rewrites raw HTML nodes into plain-text equivalents. @@ -23,12 +23,14 @@ function preserveIndent(line: string): string { if (char === ' ') { output += NBSP; index += 1; + continue; } if (char === '\t') { output += TAB_AS_SPACES; index += 1; + continue; } @@ -71,12 +73,12 @@ export const remarkLiteralHtml: Plugin<[], Root> = () => { if (!PHRASE_PARENTS.has(parent.type as string)) { const paragraph: Paragraph = { - type: 'paragraph', children: replacement as Paragraph['children'], - data: { literalHtml: true } + data: { literalHtml: true }, + type: 'paragraph' }; - const siblings = parent.children as unknown as Content[]; + siblings.splice(index, 1, paragraph as unknown as Content); if (index > 0) { diff --git a/tools/ui/src/lib/components/app/content/MentionBadge.svelte b/tools/ui/src/lib/components/app/content/MentionBadge.svelte new file mode 100644 index 000000000000..feda3aa43ac6 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MentionBadge.svelte @@ -0,0 +1,35 @@ + + + + + + {#each getMentionBadgeIconPaths(path) as d (d)} + + {/each} + + + {label} + diff --git a/tools/ui/src/lib/components/app/content/MentionText.svelte b/tools/ui/src/lib/components/app/content/MentionText.svelte new file mode 100644 index 000000000000..0a4bc0eebede --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MentionText.svelte @@ -0,0 +1,17 @@ + + + + +{#each segments as segment, index (index)}{#if segment.mention}{:else}{segment.text}{/if}{/each} diff --git a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte new file mode 100644 index 000000000000..d227e39e3d90 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte @@ -0,0 +1,124 @@ + + +
    + +
    +
    +
    + + +
    diff --git a/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte b/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte new file mode 100644 index 000000000000..62cdbadea5b2 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte @@ -0,0 +1,81 @@ + + +
    +
    + + + {Math.round(scale * 100)}% + + + +
    + + + +
    + + +
    +
    diff --git a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte index 41d59324cb47..e88e0d4cdf9d 100644 --- a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte +++ b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte @@ -1,11 +1,11 @@
    - +
    {@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 e468a441ed49..9b43fe9ccaeb 100644 --- a/tools/ui/src/lib/components/app/content/index.ts +++ b/tools/ui/src/lib/components/app/content/index.ts @@ -31,6 +31,20 @@ */ export { default as MarkdownContent } from './MarkdownContent/MarkdownContent.svelte'; +/** + * **MentionText** - Plain text with file mention badges + * + * Renders a message verbatim, turning only `[name](file://path)` links + * into the same badge chips the markdown path draws. Nothing else is + * interpreted, so pasted code keeps its `#` comments and underscores. + * + * @example + * ```svelte + * + * ``` + */ +export { default as MentionText } from './MentionText.svelte'; + /** * **SyntaxHighlightedCode** - Code syntax highlighting * @@ -68,7 +82,6 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte * ```svelte * @@ -77,3 +90,38 @@ 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 + * + * Renders Mermaid-generated SVG diagrams with zoom, pan, and fit-to-view controls. + * + * **Features:** + * - Mouse wheel zoom in/out + * - Click-drag panning with pointer capture + * - Fit to view and reset view controls + * - Download as SVG + * - Responsive scaling with viewBox detection + * + * @example + * ```svelte + * + * ``` + */ +export { default as MermaidPreview } from './MermaidPreview.svelte'; diff --git a/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte index 533301dfdad3..5ce0de9ae625 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogChatAttachmentsPreview.svelte @@ -1,9 +1,9 @@ @@ -69,19 +70,19 @@ diff --git a/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte b/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte index ff1005313e5c..76a470cfa35b 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogChatError.svelte @@ -1,6 +1,6 @@ - + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte b/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte index fe5d9b504b88..4bd5988a09fe 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogCodePreview.svelte @@ -1,6 +1,6 @@ - + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte index becc658d3c57..8a503208f06c 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogConfirmation.svelte @@ -1,7 +1,7 @@ - + @@ -70,9 +71,10 @@ {cancelText} + {confirmText} diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte new file mode 100644 index 000000000000..d83c5f0fe6f7 --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte @@ -0,0 +1,87 @@ + + + + + + + + Rename conversation + + + Choose a new title for this conversation. + + + + + + + + + + Cancel + + + + + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte index 7373250850cc..6e6260eb1613 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte @@ -1,6 +1,6 @@ - - - - - Update Conversation Title? - - - Do you want to update the conversation title to match the first message content? - - - -
    -
    -

    Current title:

    - -

    {currentTitle}

    -
    - -
    -

    New title would be:

    - -

    {newTitle}

    -
    -
    - - - - - - -
    -
    diff --git a/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte b/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte index f875b0abaeba..14417d59a89f 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte @@ -1,6 +1,6 @@ - + diff --git a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte index c112bde9f682..79bfc1ee087e 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte @@ -1,14 +1,14 @@ - + @@ -52,11 +52,11 @@
    - +