]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
(webui) REFACTOR: UI primitives and polish (#19551)
authorAleksander Grygier <redacted>
Thu, 12 Feb 2026 11:21:00 +0000 (12:21 +0100)
committerGitHub <redacted>
Thu, 12 Feb 2026 11:21:00 +0000 (12:21 +0100)
* webui: UI primitives and polish (non-MCP)

* chore: update webui build output

36 files changed:
tools/server/public/index.html.gz
tools/server/webui/src/app.css
tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/actions/ActionIconRemove.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/actions/ActionIconsCodeBlock.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/actions/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/badges/BadgeChatStatistic.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/badges/BadgeInfo.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/badges/BadgeModality.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/badges/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/content/CollapsibleContentBlock.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/content/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte
tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/misc/KeyboardShortcutInfo.svelte
tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/misc/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/navigation/DropdownMenuActions.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/navigation/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte
tools/server/webui/src/lib/components/app/server/ServerStatus.svelte
tools/server/webui/src/lib/components/app/server/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/badge/badge.svelte
tools/server/webui/src/lib/components/ui/button/button.svelte
tools/server/webui/src/lib/components/ui/card/card.svelte
tools/server/webui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
tools/server/webui/src/lib/components/ui/input/input.svelte
tools/server/webui/src/lib/components/ui/sidebar/sidebar-trigger.svelte
tools/server/webui/src/lib/components/ui/switch/switch.svelte
tools/server/webui/src/lib/components/ui/tooltip/tooltip-content.svelte
tools/server/webui/src/lib/utils/api-fetch.ts
tools/server/webui/src/lib/utils/index.ts

index 327386f413a633359ac0fe4fd25a7a42f15fe749..64a6402fc9dc94d4792dbe950aee98cff702ef85 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 9705040a4de818e40b46fc9c684c7caa8ddc9732..3ab21f0cc7b69e37ab24356e58b74e35e56745da 100644 (file)
        --popover-foreground: oklch(0.145 0 0);
        --primary: oklch(0.205 0 0);
        --primary-foreground: oklch(0.985 0 0);
-       --secondary: oklch(0.97 0 0);
+       --secondary: oklch(0.95 0 0);
        --secondary-foreground: oklch(0.205 0 0);
        --muted: oklch(0.97 0 0);
        --muted-foreground: oklch(0.556 0 0);
-       --accent: oklch(0.97 0 0);
+       --accent: oklch(0.95 0 0);
        --accent-foreground: oklch(0.205 0 0);
        --destructive: oklch(0.577 0.245 27.325);
        --border: oklch(0.875 0 0);
@@ -37,7 +37,7 @@
        --sidebar-accent-foreground: oklch(0.205 0 0);
        --sidebar-border: oklch(0.922 0 0);
        --sidebar-ring: oklch(0.708 0 0);
-       --code-background: oklch(0.975 0 0);
+       --code-background: oklch(0.985 0 0);
        --code-foreground: oklch(0.145 0 0);
        --layer-popover: 1000000;
 }
@@ -51,7 +51,7 @@
        --popover-foreground: oklch(0.985 0 0);
        --primary: oklch(0.922 0 0);
        --primary-foreground: oklch(0.205 0 0);
-       --secondary: oklch(0.269 0 0);
+       --secondary: oklch(0.29 0 0);
        --secondary-foreground: oklch(0.985 0 0);
        --muted: oklch(0.269 0 0);
        --muted-foreground: oklch(0.708 0 0);
        --color-sidebar-ring: var(--sidebar-ring);
 }
 
+:root {
+       --chat-form-area-height: 8rem;
+       --chat-form-area-offset: 2rem;
+       --max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
+}
+
+@media (min-width: 640px) {
+       :root {
+               --chat-form-area-height: 24rem;
+               --chat-form-area-offset: 12rem;
+       }
+}
+
 @layer base {
        * {
                @apply border-border outline-ring/50;
        }
+
        body {
                @apply bg-background text-foreground;
+               scrollbar-width: thin;
+               scrollbar-gutter: stable;
+       }
+
+       /* Global scrollbar styling - visible only on hover */
+       * {
+               scrollbar-width: thin;
+               scrollbar-color: transparent transparent;
+               transition: scrollbar-color 0.2s ease;
+       }
+
+       *:hover {
+               scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
+       }
+
+       *::-webkit-scrollbar {
+               width: 6px;
+               height: 6px;
+       }
+
+       *::-webkit-scrollbar-track {
+               background: transparent;
+       }
+
+       *::-webkit-scrollbar-thumb {
+               background: transparent;
+               border-radius: 3px;
+               transition: background 0.2s ease;
+       }
+
+       *:hover::-webkit-scrollbar-thumb {
+               background: hsl(var(--muted-foreground) / 0.3);
+       }
+
+       *::-webkit-scrollbar-thumb:hover {
+               background: hsl(var(--muted-foreground) / 0.5);
        }
 }
 
diff --git a/tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte b/tools/server/webui/src/lib/components/app/actions/ActionIcon.svelte
new file mode 100644 (file)
index 0000000..4494ea8
--- /dev/null
@@ -0,0 +1,48 @@
+<script lang="ts">
+       import { Button } from '$lib/components/ui/button';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import type { Component } from 'svelte';
+
+       interface Props {
+               icon: Component;
+               tooltip: string;
+               variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
+               size?: 'default' | 'sm' | 'lg' | 'icon';
+               class?: string;
+               disabled?: boolean;
+               onclick: () => void;
+               'aria-label'?: string;
+       }
+
+       let {
+               icon,
+               tooltip,
+               variant = 'ghost',
+               size = 'sm',
+               class: className = '',
+               disabled = false,
+               onclick,
+               'aria-label': ariaLabel
+       }: Props = $props();
+</script>
+
+<Tooltip.Root>
+       <Tooltip.Trigger>
+               <Button
+                       {variant}
+                       {size}
+                       {disabled}
+                       {onclick}
+                       class="h-6 w-6 p-0 {className} flex"
+                       aria-label={ariaLabel || tooltip}
+               >
+                       {@const IconComponent = icon}
+
+                       <IconComponent class="h-3 w-3" />
+               </Button>
+       </Tooltip.Trigger>
+
+       <Tooltip.Content>
+               <p>{tooltip}</p>
+       </Tooltip.Content>
+</Tooltip.Root>
diff --git a/tools/server/webui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte b/tools/server/webui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte
new file mode 100644 (file)
index 0000000..bf6cd4f
--- /dev/null
@@ -0,0 +1,18 @@
+<script lang="ts">
+       import { Copy } from '@lucide/svelte';
+       import { copyToClipboard } from '$lib/utils';
+
+       interface Props {
+               ariaLabel?: string;
+               canCopy?: boolean;
+               text: string;
+       }
+
+       let { ariaLabel = 'Copy to clipboard', canCopy = true, text }: Props = $props();
+</script>
+
+<Copy
+       class="h-3 w-3 flex-shrink-0 cursor-{canCopy ? 'pointer' : 'not-allowed'}"
+       aria-label={ariaLabel}
+       onclick={() => canCopy && copyToClipboard(text)}
+/>
diff --git a/tools/server/webui/src/lib/components/app/actions/ActionIconRemove.svelte b/tools/server/webui/src/lib/components/app/actions/ActionIconRemove.svelte
new file mode 100644 (file)
index 0000000..1ae3d21
--- /dev/null
@@ -0,0 +1,26 @@
+<script lang="ts">
+       import { X } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+
+       interface Props {
+               id: string;
+               onRemove?: (id: string) => void;
+               class?: string;
+       }
+
+       let { id, onRemove, class: className = '' }: Props = $props();
+</script>
+
+<Button
+       type="button"
+       variant="ghost"
+       size="sm"
+       class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
+       onclick={(e: MouseEvent) => {
+               e.stopPropagation();
+               onRemove?.(id);
+       }}
+       aria-label="Remove file"
+>
+       <X class="h-3 w-3" />
+</Button>
diff --git a/tools/server/webui/src/lib/components/app/actions/ActionIconsCodeBlock.svelte b/tools/server/webui/src/lib/components/app/actions/ActionIconsCodeBlock.svelte
new file mode 100644 (file)
index 0000000..54ff0af
--- /dev/null
@@ -0,0 +1,46 @@
+<script lang="ts">
+       import { Eye } from '@lucide/svelte';
+       import ActionIconCopyToClipboard from '$lib/components/app/actions/ActionIconCopyToClipboard.svelte';
+       import { FileTypeText } from '$lib/enums';
+
+       interface Props {
+               code: string;
+               language: string;
+               disabled?: boolean;
+               onPreview?: (code: string, language: string) => void;
+       }
+
+       let { code, language, disabled = false, onPreview }: Props = $props();
+
+       const showPreview = $derived(language?.toLowerCase() === FileTypeText.HTML);
+
+       function handlePreview() {
+               if (disabled) return;
+               onPreview?.(code, language);
+       }
+</script>
+
+<div class="code-block-actions">
+       <div class="copy-code-btn" class:opacity-50={disabled} class:!cursor-not-allowed={disabled}>
+               <ActionIconCopyToClipboard
+                       text={code}
+                       canCopy={!disabled}
+                       ariaLabel={disabled ? 'Code incomplete' : 'Copy code'}
+               />
+       </div>
+
+       {#if showPreview}
+               <button
+                       class="preview-code-btn"
+                       class:opacity-50={disabled}
+                       class:!cursor-not-allowed={disabled}
+                       title={disabled ? 'Code incomplete' : 'Preview code'}
+                       aria-label="Preview code"
+                       aria-disabled={disabled}
+                       type="button"
+                       onclick={handlePreview}
+               >
+                       <Eye size={16} />
+               </button>
+       {/if}
+</div>
diff --git a/tools/server/webui/src/lib/components/app/actions/index.ts b/tools/server/webui/src/lib/components/app/actions/index.ts
new file mode 100644 (file)
index 0000000..43485c7
--- /dev/null
@@ -0,0 +1,19 @@
+/**
+ *
+ * ACTIONS
+ *
+ * Small interactive components for user actions.
+ *
+ */
+
+/** Styled icon button for action triggers with tooltip. */
+export { default as ActionIcon } from './ActionIcon.svelte';
+
+/** Code block actions component (copy, preview). */
+export { default as ActionIconsCodeBlock } from './ActionIconsCodeBlock.svelte';
+
+/** Copy-to-clipboard icon button with click handler. */
+export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
+
+/** Remove/delete icon button with X icon. */
+export { default as ActionIconRemove } from './ActionIconRemove.svelte';
diff --git a/tools/server/webui/src/lib/components/app/badges/BadgeChatStatistic.svelte b/tools/server/webui/src/lib/components/app/badges/BadgeChatStatistic.svelte
new file mode 100644 (file)
index 0000000..a2b28d2
--- /dev/null
@@ -0,0 +1,44 @@
+<script lang="ts">
+       import { BadgeInfo } from '$lib/components/app';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { copyToClipboard } from '$lib/utils';
+       import type { Component } from 'svelte';
+
+       interface Props {
+               class?: string;
+               icon: Component;
+               value: string | number;
+               tooltipLabel?: string;
+       }
+
+       let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props();
+
+       function handleClick() {
+               void copyToClipboard(String(value));
+       }
+</script>
+
+{#if tooltipLabel}
+       <Tooltip.Root>
+               <Tooltip.Trigger>
+                       <BadgeInfo class={className} onclick={handleClick}>
+                               {#snippet icon()}
+                                       <Icon class="h-3 w-3" />
+                               {/snippet}
+
+                               {value}
+                       </BadgeInfo>
+               </Tooltip.Trigger>
+               <Tooltip.Content>
+                       <p>{tooltipLabel}</p>
+               </Tooltip.Content>
+       </Tooltip.Root>
+{:else}
+       <BadgeInfo class={className} onclick={handleClick}>
+               {#snippet icon()}
+                       <Icon class="h-3 w-3" />
+               {/snippet}
+
+               {value}
+       </BadgeInfo>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/badges/BadgeInfo.svelte b/tools/server/webui/src/lib/components/app/badges/BadgeInfo.svelte
new file mode 100644 (file)
index 0000000..c70af6f
--- /dev/null
@@ -0,0 +1,27 @@
+<script lang="ts">
+       import { cn } from '$lib/components/ui/utils';
+       import type { Snippet } from 'svelte';
+
+       interface Props {
+               children: Snippet;
+               class?: string;
+               icon?: Snippet;
+               onclick?: () => void;
+       }
+
+       let { children, class: className = '', icon, onclick }: Props = $props();
+</script>
+
+<button
+       class={cn(
+               'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
+               className
+       )}
+       {onclick}
+>
+       {#if icon}
+               {@render icon()}
+       {/if}
+
+       {@render children()}
+</button>
diff --git a/tools/server/webui/src/lib/components/app/badges/BadgeModality.svelte b/tools/server/webui/src/lib/components/app/badges/BadgeModality.svelte
new file mode 100644 (file)
index 0000000..a0d5e86
--- /dev/null
@@ -0,0 +1,39 @@
+<script lang="ts">
+       import { ModelModality } from '$lib/enums';
+       import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
+       import { cn } from '$lib/components/ui/utils';
+
+       type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
+
+       interface Props {
+               modalities: ModelModality[];
+               class?: string;
+       }
+
+       let { modalities, class: className = '' }: Props = $props();
+
+       // Filter to only modalities that have icons (VISION, AUDIO)
+       const displayableModalities = $derived(
+               modalities.filter(
+                       (m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO
+               )
+       );
+</script>
+
+{#each displayableModalities as modality, index (index)}
+       {@const IconComponent = MODALITY_ICONS[modality]}
+       {@const label = MODALITY_LABELS[modality]}
+
+       <span
+               class={cn(
+                       'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
+                       className
+               )}
+       >
+               {#if IconComponent}
+                       <IconComponent class="h-3 w-3" />
+               {/if}
+
+               {label}
+       </span>
+{/each}
diff --git a/tools/server/webui/src/lib/components/app/badges/index.ts b/tools/server/webui/src/lib/components/app/badges/index.ts
new file mode 100644 (file)
index 0000000..860afe3
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ *
+ * BADGES & INDICATORS
+ *
+ * Small visual indicators for status and metadata.
+ *
+ */
+
+/** Badge displaying chat statistics (tokens, timing). */
+export { default as BadgeChatStatistic } from './BadgeChatStatistic.svelte';
+
+/** Generic info badge with optional tooltip and click handler. */
+export { default as BadgeInfo } from './BadgeInfo.svelte';
+
+/** Badge indicating model modality (vision, audio, tools). */
+export { default as BadgeModality } from './BadgeModality.svelte';
diff --git a/tools/server/webui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/server/webui/src/lib/components/app/content/CollapsibleContentBlock.svelte
new file mode 100644 (file)
index 0000000..082738d
--- /dev/null
@@ -0,0 +1,97 @@
+<script lang="ts">
+       import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
+       import * as Collapsible from '$lib/components/ui/collapsible/index.js';
+       import { buttonVariants } from '$lib/components/ui/button/index.js';
+       import { Card } from '$lib/components/ui/card';
+       import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+       import type { Snippet } from 'svelte';
+       import type { Component } from 'svelte';
+
+       interface Props {
+               open?: boolean;
+               class?: string;
+               icon?: Component;
+               iconClass?: string;
+               title: string;
+               subtitle?: string;
+               isStreaming?: boolean;
+               onToggle?: () => void;
+               children: Snippet;
+       }
+
+       let {
+               open = $bindable(false),
+               class: className = '',
+               icon: Icon,
+               iconClass = 'h-4 w-4',
+               title,
+               subtitle,
+               isStreaming = false,
+               onToggle,
+               children
+       }: Props = $props();
+
+       let contentContainer: HTMLDivElement | undefined = $state();
+       const autoScroll = createAutoScrollController();
+
+       $effect(() => {
+               autoScroll.setContainer(contentContainer);
+       });
+
+       $effect(() => {
+               // Only auto-scroll when open and streaming
+               autoScroll.updateInterval(open && isStreaming);
+       });
+
+       function handleScroll() {
+               autoScroll.handleScroll();
+       }
+</script>
+
+<Collapsible.Root
+       {open}
+       onOpenChange={(value) => {
+               open = value;
+               onToggle?.();
+       }}
+       class={className}
+>
+       <Card class="gap-0 border-muted bg-muted/30 py-0">
+               <Collapsible.Trigger class="flex w-full cursor-pointer items-center justify-between p-3">
+                       <div class="flex items-center gap-2 text-muted-foreground">
+                               {#if Icon}
+                                       <Icon class={iconClass} />
+                               {/if}
+
+                               <span class="font-mono text-sm font-medium">{title}</span>
+
+                               {#if subtitle}
+                                       <span class="text-xs italic">{subtitle}</span>
+                               {/if}
+                       </div>
+
+                       <div
+                               class={buttonVariants({
+                                       variant: 'ghost',
+                                       size: 'sm',
+                                       class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
+                               })}
+                       >
+                               <ChevronsUpDownIcon class="h-4 w-4" />
+
+                               <span class="sr-only">Toggle content</span>
+                       </div>
+               </Collapsible.Trigger>
+
+               <Collapsible.Content>
+                       <div
+                               bind:this={contentContainer}
+                               class="overflow-y-auto border-t border-muted px-3 pb-3"
+                               onscroll={handleScroll}
+                               style="min-height: var(--min-message-height); max-height: var(--max-message-height);"
+                       >
+                               {@render children()}
+                       </div>
+               </Collapsible.Content>
+       </Card>
+</Collapsible.Root>
diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte
new file mode 100644 (file)
index 0000000..ef6c7e0
--- /dev/null
@@ -0,0 +1,1201 @@
+<script lang="ts">
+       import { remark } from 'remark';
+       import remarkBreaks from 'remark-breaks';
+       import remarkGfm from 'remark-gfm';
+       import remarkMath from 'remark-math';
+       import rehypeHighlight from 'rehype-highlight';
+       import remarkRehype from 'remark-rehype';
+       import rehypeKatex from 'rehype-katex';
+       import rehypeStringify from 'rehype-stringify';
+       import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
+       import type { Root as MdastRoot } from 'mdast';
+       import { browser } from '$app/environment';
+       import { onDestroy, tick } from 'svelte';
+       import { SvelteMap } from 'svelte/reactivity';
+       import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
+       import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
+       import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
+       import { remarkLiteralHtml } from '$lib/markdown/literal-html';
+       import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
+       import {
+               IMAGE_NOT_ERROR_BOUND_SELECTOR,
+               DATA_ERROR_BOUND_ATTR,
+               DATA_ERROR_HANDLED_ATTR,
+               BOOL_TRUE_STRING
+       } from '$lib/constants/markdown';
+       import { FileTypeText } from '$lib/enums/files';
+       import {
+               highlightCode,
+               detectIncompleteCodeBlock,
+               type IncompleteCodeBlock
+       } from '$lib/utils/code';
+       import '$styles/katex-custom.scss';
+       import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+       import githubLightCss from 'highlight.js/styles/github.css?inline';
+       import { mode } from 'mode-watcher';
+       import ActionIconsCodeBlock from '$lib/components/app/actions/ActionIconsCodeBlock.svelte';
+       import DialogCodePreview from '$lib/components/app/misc/CodePreviewDialog.svelte';
+       import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+       import type { DatabaseMessageExtra } from '$lib/types/database';
+
+       interface Props {
+               attachments?: DatabaseMessageExtra[];
+               content: string;
+               class?: string;
+               disableMath?: boolean;
+       }
+
+       interface MarkdownBlock {
+               id: string;
+               html: string;
+               contentHash?: string;
+       }
+
+       let { content, attachments, class: className = '', disableMath = false }: Props = $props();
+
+       let containerRef = $state<HTMLDivElement>();
+       let renderedBlocks = $state<MarkdownBlock[]>([]);
+       let unstableBlockHtml = $state('');
+       let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
+       let previewDialogOpen = $state(false);
+       let previewCode = $state('');
+       let previewLanguage = $state('text');
+       let streamingCodeScrollContainer = $state<HTMLDivElement>();
+
+       // Auto-scroll controller for streaming code block content
+       const streamingAutoScroll = createAutoScrollController();
+
+       let pendingMarkdown: string | null = null;
+       let isProcessing = false;
+
+       // Per-instance transform cache, avoids re-transforming stable blocks during streaming
+       // Garbage collected when component is destroyed (on conversation change)
+       const transformCache = new SvelteMap<string, string>();
+       let previousContent = '';
+
+       const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
+
+       let processor = $derived(() => {
+               void attachments;
+               // eslint-disable-next-line @typescript-eslint/no-explicit-any
+               let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
+
+               if (!disableMath) {
+                       proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
+               }
+
+               proc = proc
+                       .use(remarkBreaks) // Convert line breaks to <br>
+                       .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
+                       .use(remarkRehype); // Convert Markdown AST to rehype
+
+               if (!disableMath) {
+                       proc = proc.use(rehypeKatex); // Render math using KaTeX
+               }
+
+               return proc
+                       .use(rehypeHighlight, {
+                               aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }
+                       }) // Add syntax highlighting
+                       .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
+                       .use(rehypeEnhanceLinks) // Add target="_blank" to links
+                       .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+                       .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
+       });
+
+       /**
+        * Removes click event listeners from copy and preview buttons.
+        * Called on component destroy.
+        */
+       function cleanupEventListeners() {
+               if (!containerRef) return;
+
+               const copyButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
+               const previewButtons = containerRef.querySelectorAll<HTMLButtonElement>('.preview-code-btn');
+
+               for (const button of copyButtons) {
+                       button.removeEventListener('click', handleCopyClick);
+               }
+
+               for (const button of previewButtons) {
+                       button.removeEventListener('click', handlePreviewClick);
+               }
+       }
+
+       /**
+        * Removes this component's highlight.js theme style from the document head.
+        * Called on component destroy to clean up injected styles.
+        */
+       function cleanupHighlightTheme() {
+               if (!browser) return;
+
+               const existingTheme = document.getElementById(themeStyleId);
+               existingTheme?.remove();
+       }
+
+       /**
+        * Loads the appropriate highlight.js theme based on dark/light mode.
+        * Injects a scoped style element into the document head.
+        * @param isDark - Whether to load the dark theme (true) or light theme (false)
+        */
+       function loadHighlightTheme(isDark: boolean) {
+               if (!browser) return;
+
+               const existingTheme = document.getElementById(themeStyleId);
+               existingTheme?.remove();
+
+               const style = document.createElement('style');
+               style.id = themeStyleId;
+               style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+               document.head.appendChild(style);
+       }
+
+       /**
+        * 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
+        */
+       function getCodeInfoFromTarget(target: HTMLElement) {
+               const wrapper = target.closest('.code-block-wrapper');
+
+               if (!wrapper) {
+                       console.error('No wrapper found');
+                       return null;
+               }
+
+               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
+
+               if (!codeElement) {
+                       console.error('No code element found in wrapper');
+                       return null;
+               }
+
+               const rawCode = codeElement.textContent ?? '';
+
+               const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
+               const language = languageLabel?.textContent?.trim() || 'text';
+
+               return { rawCode, language };
+       }
+
+       /**
+        * 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
+        */
+       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.
+        */
+       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}`;
+       }
+
+       /**
+        * Check if we're in append-only mode (streaming).
+        */
+       function isAppendMode(newContent: string): boolean {
+               return previousContent.length > 0 && newContent.startsWith(previousContent);
+       }
+
+       /**
+        * Transforms a single MDAST node to HTML string with caching.
+        * Runs the full remark/rehype plugin pipeline (GFM, math, syntax highlighting, etc.)
+        * on an isolated single-node tree, then stringifies the resulting HAST to HTML.
+        * Results are cached by node position hash for streaming performance.
+        * @param processorInstance - The remark/rehype processor instance
+        * @param node - The MDAST node to transform
+        * @param index - Node index for hash fallback
+        * @returns Object containing the HTML string and cache hash
+        */
+       async function transformMdastNode(
+               processorInstance: ReturnType<typeof processor>,
+               node: unknown,
+               index: number
+       ): Promise<{ html: string; hash: string }> {
+               const hash = getMdastNodeHash(node, index);
+
+               const cached = transformCache.get(hash);
+               if (cached) {
+                       return { html: cached, hash };
+               }
+
+               const singleNodeRoot = { type: 'root', children: [node] };
+               const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
+               const html = processorInstance.stringify(transformedRoot);
+
+               transformCache.set(hash, html);
+
+               return { html, hash };
+       }
+
+       /**
+        * Handles click events on copy buttons within code blocks.
+        * Copies the raw code content to the clipboard.
+        * @param event - The click event from the copy button
+        */
+       async function handleCopyClick(event: Event) {
+               event.preventDefault();
+               event.stopPropagation();
+
+               const target = event.currentTarget as HTMLButtonElement | null;
+
+               if (!target) {
+                       return;
+               }
+
+               const info = getCodeInfoFromTarget(target);
+
+               if (!info) {
+                       return;
+               }
+
+               try {
+                       await copyCodeToClipboard(info.rawCode);
+               } catch (error) {
+                       console.error('Failed to copy code:', error);
+               }
+       }
+
+       /**
+        * Handles preview dialog open state changes.
+        * Clears preview content when dialog is closed.
+        * @param open - Whether the dialog is being opened or closed
+        */
+       function handlePreviewDialogOpenChange(open: boolean) {
+               previewDialogOpen = open;
+
+               if (!open) {
+                       previewCode = '';
+                       previewLanguage = 'text';
+               }
+       }
+
+       /**
+        * Handles click events on preview buttons within HTML code blocks.
+        * Opens a preview dialog with the rendered HTML content.
+        * @param event - The click event from the preview button
+        */
+       function handlePreviewClick(event: Event) {
+               event.preventDefault();
+               event.stopPropagation();
+
+               const target = event.currentTarget as HTMLButtonElement | null;
+
+               if (!target) {
+                       return;
+               }
+
+               const info = getCodeInfoFromTarget(target);
+
+               if (!info) {
+                       return;
+               }
+
+               previewCode = info.rawCode;
+               previewLanguage = info.language;
+               previewDialogOpen = true;
+       }
+
+       /**
+        * Processes markdown content into stable and unstable HTML blocks.
+        * Uses incremental rendering: stable blocks are cached, unstable block is re-rendered.
+        * Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
+        * @param markdown - The raw markdown string to process
+        */
+       async function processMarkdown(markdown: string) {
+               // Early exit if content unchanged (can happen with rapid coalescing)
+               if (markdown === previousContent) {
+                       return;
+               }
+
+               if (!markdown) {
+                       renderedBlocks = [];
+                       unstableBlockHtml = '';
+                       incompleteCodeBlock = null;
+                       previousContent = '';
+                       return;
+               }
+
+               // Check for incomplete code block at the end of content
+               const incompleteBlock = detectIncompleteCodeBlock(markdown);
+
+               if (incompleteBlock) {
+                       // Process only the prefix (content before the incomplete code block)
+                       const prefixMarkdown = markdown.slice(0, incompleteBlock.openingIndex);
+
+                       if (prefixMarkdown.trim()) {
+                               const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
+                               const processorInstance = processor();
+                               const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
+                               const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
+                               const nextBlocks: MarkdownBlock[] = [];
+
+                               // Check if we're in append mode for cache reuse
+                               const appendMode = isAppendMode(prefixMarkdown);
+                               const previousBlockCount = appendMode ? renderedBlocks.length : 0;
+
+                               // All prefix blocks are now stable since code block is separate
+                               for (let index = 0; index < mdastChildren.length; index++) {
+                                       const child = mdastChildren[index];
+
+                                       // In append mode, reuse previous blocks if unchanged
+                                       if (appendMode && index < previousBlockCount) {
+                                               const prevBlock = renderedBlocks[index];
+                                               const currentHash = getMdastNodeHash(child, index);
+
+                                               if (prevBlock?.contentHash === currentHash) {
+                                                       nextBlocks.push(prevBlock);
+
+                                                       continue;
+                                               }
+                                       }
+
+                                       // Transform this block (with caching)
+                                       const { html, hash } = await transformMdastNode(processorInstance, child, index);
+                                       const id = getHastNodeId(
+                                               { position: (child as { position?: unknown }).position } as HastRootContent,
+                                               index
+                                       );
+
+                                       nextBlocks.push({ id, html, contentHash: hash });
+                               }
+
+                               renderedBlocks = nextBlocks;
+                       } else {
+                               renderedBlocks = [];
+                       }
+
+                       previousContent = prefixMarkdown;
+                       unstableBlockHtml = '';
+                       incompleteCodeBlock = incompleteBlock;
+
+                       return;
+               }
+
+               // No incomplete code block - use standard processing
+               incompleteCodeBlock = null;
+
+               const normalized = preprocessLaTeX(markdown);
+               const processorInstance = processor();
+               const ast = processorInstance.parse(normalized) as MdastRoot;
+               const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
+               const stableCount = Math.max(mdastChildren.length - 1, 0);
+               const nextBlocks: MarkdownBlock[] = [];
+
+               // Check if we're in append mode for cache reuse
+               const appendMode = isAppendMode(markdown);
+               const previousBlockCount = appendMode ? renderedBlocks.length : 0;
+
+               for (let index = 0; index < stableCount; index++) {
+                       const child = mdastChildren[index];
+
+                       // In append mode, reuse previous blocks if unchanged
+                       if (appendMode && index < previousBlockCount) {
+                               const prevBlock = renderedBlocks[index];
+                               const currentHash = getMdastNodeHash(child, index);
+                               if (prevBlock?.contentHash === currentHash) {
+                                       nextBlocks.push(prevBlock);
+
+                                       continue;
+                               }
+                       }
+
+                       // Transform this block (with caching)
+                       const { html, hash } = await transformMdastNode(processorInstance, child, index);
+                       const id = getHastNodeId(
+                               { position: (child as { position?: unknown }).position } as HastRootContent,
+                               index
+                       );
+
+                       nextBlocks.push({ id, html, contentHash: hash });
+               }
+
+               let unstableHtml = '';
+
+               if (mdastChildren.length > stableCount) {
+                       const unstableChild = mdastChildren[stableCount];
+                       const singleNodeRoot = { type: 'root', children: [unstableChild] };
+                       const transformedRoot = (await processorInstance.run(
+                               singleNodeRoot as MdastRoot
+                       )) as HastRoot;
+
+                       unstableHtml = processorInstance.stringify(transformedRoot);
+               }
+
+               renderedBlocks = nextBlocks;
+               previousContent = markdown;
+               await tick(); // Force DOM sync before updating unstable HTML block
+               unstableBlockHtml = unstableHtml;
+       }
+
+       /**
+        * Attaches click event listeners to copy and preview buttons in code blocks.
+        * Uses data-listener-bound attribute to prevent duplicate bindings.
+        */
+       function setupCodeBlockActions() {
+               if (!containerRef) return;
+
+               const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
+
+               for (const wrapper of wrappers) {
+                       const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
+                       const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
+
+                       if (copyButton && copyButton.dataset.listenerBound !== 'true') {
+                               copyButton.dataset.listenerBound = 'true';
+                               copyButton.addEventListener('click', handleCopyClick);
+                       }
+
+                       if (previewButton && previewButton.dataset.listenerBound !== 'true') {
+                               previewButton.dataset.listenerBound = 'true';
+                               previewButton.addEventListener('click', handlePreviewClick);
+                       }
+               }
+       }
+
+       /**
+        * Attaches error handlers to images to show fallback UI when loading fails (e.g., CORS).
+        * Uses data-error-bound attribute to prevent duplicate bindings.
+        */
+       function setupImageErrorHandlers() {
+               if (!containerRef) return;
+
+               const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
+
+               for (const img of images) {
+                       img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
+                       img.addEventListener('error', handleImageError);
+               }
+       }
+
+       /**
+        * Handles image load errors by replacing the image with a fallback UI.
+        * Shows a placeholder with a link to open the image in a new tab.
+        */
+       function handleImageError(event: Event) {
+               const img = event.target as HTMLImageElement;
+               if (!img || !img.src) return;
+
+               // Don't handle data URLs or already-handled images
+               if (img.src.startsWith('data:') || img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING)
+                       return;
+               img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
+
+               const src = img.src;
+               // Create fallback element
+               const fallback = document.createElement('div');
+               fallback.className = 'image-load-error';
+               fallback.innerHTML = getImageErrorFallbackHtml(src);
+
+               // Replace image with fallback
+               img.parentNode?.replaceChild(fallback, img);
+       }
+
+       /**
+        * Queues markdown for processing with coalescing support.
+        * Only processes the latest markdown when multiple updates arrive quickly.
+        * Uses requestAnimationFrame to yield to browser paint between batches.
+        * @param markdown - The markdown content to render
+        */
+       async function updateRenderedBlocks(markdown: string) {
+               pendingMarkdown = markdown;
+
+               if (isProcessing) {
+                       return;
+               }
+
+               isProcessing = true;
+
+               try {
+                       while (pendingMarkdown !== null) {
+                               const nextMarkdown = pendingMarkdown;
+                               pendingMarkdown = null;
+
+                               await processMarkdown(nextMarkdown);
+
+                               // Yield to browser for paint. During this, new chunks coalesce
+                               // into pendingMarkdown, so we always render the latest state.
+                               if (pendingMarkdown !== null) {
+                                       await new Promise((resolve) => requestAnimationFrame(resolve));
+                               }
+                       }
+               } catch (error) {
+                       console.error('Failed to process markdown:', error);
+                       renderedBlocks = [];
+                       unstableBlockHtml = markdown.replace(/\n/g, '<br>');
+               } finally {
+                       isProcessing = false;
+               }
+       }
+
+       $effect(() => {
+               const currentMode = mode.current;
+               const isDark = currentMode === 'dark';
+
+               loadHighlightTheme(isDark);
+       });
+
+       $effect(() => {
+               updateRenderedBlocks(content);
+       });
+
+       $effect(() => {
+               const hasRenderedBlocks = renderedBlocks.length > 0;
+               const hasUnstableBlock = Boolean(unstableBlockHtml);
+
+               if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
+                       setupCodeBlockActions();
+                       setupImageErrorHandlers();
+               }
+       });
+
+       // Auto-scroll for streaming code block
+       $effect(() => {
+               streamingAutoScroll.setContainer(streamingCodeScrollContainer);
+       });
+
+       $effect(() => {
+               streamingAutoScroll.updateInterval(incompleteCodeBlock !== null);
+       });
+
+       onDestroy(() => {
+               cleanupEventListeners();
+               cleanupHighlightTheme();
+               streamingAutoScroll.destroy();
+       });
+</script>
+
+<div bind:this={containerRef} class={className}>
+       {#each renderedBlocks as block (block.id)}
+               <div class="markdown-block" data-block-id={block.id}>
+                       <!-- eslint-disable-next-line no-at-html-tags -->
+                       {@html block.html}
+               </div>
+       {/each}
+
+       {#if unstableBlockHtml}
+               <div class="markdown-block markdown-block--unstable" data-block-id="unstable">
+                       <!-- eslint-disable-next-line no-at-html-tags -->
+                       {@html unstableBlockHtml}
+               </div>
+       {/if}
+
+       {#if incompleteCodeBlock}
+               <div class="code-block-wrapper streaming-code-block relative">
+                       <div class="code-block-header">
+                               <span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
+                               <ActionIconsCodeBlock
+                                       code={incompleteCodeBlock.code}
+                                       language={incompleteCodeBlock.language || 'text'}
+                                       disabled={true}
+                                       onPreview={(code: string, lang: string) => {
+                                               previewCode = code;
+                                               previewLanguage = lang;
+                                               previewDialogOpen = true;
+                                       }}
+                               />
+                       </div>
+                       <div
+                               bind:this={streamingCodeScrollContainer}
+                               class="streaming-code-scroll-container"
+                               onscroll={() => streamingAutoScroll.handleScroll()}
+                       >
+                               <pre class="streaming-code-pre"><code
+                                               class="hljs language-{incompleteCodeBlock.language || 'text'}"
+                                               >{@html highlightCode(
+                                                       incompleteCodeBlock.code,
+                                                       incompleteCodeBlock.language || 'text'
+                                               )}</code
+                                       ></pre>
+                       </div>
+               </div>
+       {/if}
+</div>
+
+<DialogCodePreview
+       open={previewDialogOpen}
+       code={previewCode}
+       language={previewLanguage}
+       onOpenChange={handlePreviewDialogOpenChange}
+/>
+
+<style>
+       .markdown-block,
+       .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 */
+       div :global(p) {
+               margin-block: 1rem;
+               line-height: 1.75;
+       }
+
+       div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
+               margin-top: 0;
+       }
+
+       /* Headers with consistent spacing */
+       div :global(h1) {
+               font-size: 1.875rem;
+               font-weight: 700;
+               line-height: 1.2;
+               margin: 1.5rem 0 0.75rem 0;
+       }
+
+       div :global(h2) {
+               font-size: 1.5rem;
+               font-weight: 600;
+               line-height: 1.3;
+               margin: 1.25rem 0 0.5rem 0;
+       }
+
+       div :global(h3) {
+               font-size: 1.25rem;
+               font-weight: 600;
+               margin: 1.5rem 0 0.5rem 0;
+               line-height: 1.4;
+       }
+
+       div :global(h4) {
+               font-size: 1.125rem;
+               font-weight: 600;
+               margin: 0.75rem 0 0.25rem 0;
+       }
+
+       div :global(h5) {
+               font-size: 1rem;
+               font-weight: 600;
+               margin: 0.5rem 0 0.25rem 0;
+       }
+
+       div :global(h6) {
+               font-size: 0.875rem;
+               font-weight: 600;
+               margin: 0.5rem 0 0.25rem 0;
+       }
+
+       /* Text formatting */
+       div :global(strong) {
+               font-weight: 600;
+       }
+
+       div :global(em) {
+               font-style: italic;
+       }
+
+       div :global(del) {
+               text-decoration: line-through;
+               opacity: 0.7;
+       }
+
+       /* Inline code */
+       div :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;
+               font-family:
+                       ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+                       'Liberation Mono', Menlo, monospace;
+       }
+
+       div :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;
+       }
+
+       div :global(pre code) {
+               padding: 0 !important;
+               display: inline !important;
+       }
+
+       div :global(code) {
+               background: transparent;
+               color: var(--code-foreground);
+       }
+
+       /* Links */
+       div :global(a) {
+               color: var(--primary);
+               text-decoration: underline;
+               text-underline-offset: 2px;
+               transition: color 0.2s ease;
+               overflow-wrap: anywhere;
+               word-break: break-all;
+       }
+
+       div :global(a:hover) {
+               color: var(--primary);
+       }
+
+       /* Lists */
+       div :global(ul) {
+               list-style-type: disc;
+               margin-left: 1.5rem;
+               margin-bottom: 1rem;
+       }
+
+       div :global(ol) {
+               list-style-type: decimal;
+               margin-left: 1.5rem;
+               margin-bottom: 1rem;
+       }
+
+       div :global(li) {
+               margin-bottom: 0.25rem;
+               padding-left: 0.5rem;
+       }
+
+       div :global(li::marker) {
+               color: var(--muted-foreground);
+       }
+
+       /* Nested lists */
+       div :global(ul ul) {
+               list-style-type: circle;
+               margin-top: 0.25rem;
+               margin-bottom: 0.25rem;
+       }
+
+       div :global(ol ol) {
+               list-style-type: lower-alpha;
+               margin-top: 0.25rem;
+               margin-bottom: 0.25rem;
+       }
+
+       /* Task lists */
+       div :global(.task-list-item) {
+               list-style: none;
+               margin-left: 0;
+               padding-left: 0;
+       }
+
+       div :global(.task-list-item-checkbox) {
+               margin-right: 0.5rem;
+               margin-top: 0.125rem;
+       }
+
+       /* Blockquotes */
+       div :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 */
+       div :global(table) {
+               width: 100%;
+               margin: 1.5rem 0;
+               border-collapse: collapse;
+               border: 1px solid var(--border);
+               border-radius: 0.375rem;
+               overflow: hidden;
+       }
+
+       div :global(th) {
+               background: hsl(var(--muted) / 0.3);
+               border: 1px solid var(--border);
+               padding: 0.5rem 0.75rem;
+               text-align: left;
+               font-weight: 600;
+       }
+
+       div :global(td) {
+               border: 1px solid var(--border);
+               padding: 0.5rem 0.75rem;
+       }
+
+       div :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 */
+       div :global(hr) {
+               border: none;
+               border-top: 1px solid var(--border);
+               margin: 1.5rem 0;
+       }
+
+       /* Images */
+       div :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 */
+
+       div :global(.code-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);
+               min-height: var(--min-message-height);
+               max-height: var(--max-message-height);
+       }
+
+       :global(.dark) div :global(.code-block-wrapper) {
+               border-color: color-mix(in oklch, var(--border) 20%, transparent);
+       }
+
+       /* Scroll container for code blocks (both streaming and completed) */
+       div :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;
+       }
+
+       div :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;
+       }
+
+       div :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;
+       }
+
+       div :global(.code-block-actions) {
+               display: flex;
+               align-items: center;
+               gap: 0.5rem;
+       }
+
+       div :global(.copy-code-btn),
+       div :global(.preview-code-btn) {
+               display: flex;
+               align-items: center;
+               justify-content: center;
+               padding: 0;
+               background: transparent;
+               color: var(--code-foreground);
+               cursor: pointer;
+               transition: all 0.2s ease;
+       }
+
+       div :global(.copy-code-btn:hover),
+       div :global(.preview-code-btn:hover) {
+               transform: scale(1.05);
+       }
+
+       div :global(.copy-code-btn:active),
+       div :global(.preview-code-btn:active) {
+               transform: scale(0.95);
+       }
+
+       div :global(.code-block-wrapper pre) {
+               background: transparent;
+               margin: 0;
+               border-radius: 0;
+               border: none;
+               font-size: 0.875rem;
+       }
+
+       /* Mentions and hashtags */
+       div :global(.mention) {
+               color: hsl(var(--primary));
+               font-weight: 500;
+               text-decoration: none;
+       }
+
+       div :global(.mention:hover) {
+               text-decoration: underline;
+       }
+
+       div :global(.hashtag) {
+               color: hsl(var(--primary));
+               font-weight: 500;
+               text-decoration: none;
+       }
+
+       div :global(.hashtag:hover) {
+               text-decoration: underline;
+       }
+
+       /* Advanced table enhancements */
+       div :global(table) {
+               transition: all 0.2s ease;
+       }
+
+       div :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);
+       }
+
+       div :global(th:hover),
+       div :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 */
+       div :global(blockquote) {
+               transition: all 0.2s ease;
+               position: relative;
+       }
+
+       div :global(blockquote:hover) {
+               border-left-width: 6px;
+               background: var(--muted);
+               transform: translateX(2px);
+       }
+
+       div :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 */
+       div :global(img) {
+               transition: all 0.3s ease;
+               cursor: pointer;
+       }
+
+       div :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 */
+       div :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;
+       }
+
+       div :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 */
+       div :global(hr) {
+               border: none;
+               height: 2px;
+               background: linear-gradient(to right, transparent, var(--border), transparent);
+               margin: 2rem 0;
+               position: relative;
+       }
+
+       div :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 */
+       div :global(.table-wrapper) {
+               overflow-x: auto;
+               margin: 1.5rem 0;
+               border-radius: 0.5rem;
+               border: 1px solid var(--border);
+       }
+
+       div :global(.table-wrapper table) {
+               margin: 0;
+               border: none;
+       }
+
+       /* Responsive adjustments */
+       @media (max-width: 640px) {
+               div :global(h1) {
+                       font-size: 1.5rem;
+               }
+
+               div :global(h2) {
+                       font-size: 1.25rem;
+               }
+
+               div :global(h3) {
+                       font-size: 1.125rem;
+               }
+
+               div :global(table) {
+                       font-size: 0.875rem;
+               }
+
+               div :global(th),
+               div :global(td) {
+                       padding: 0.375rem 0.5rem;
+               }
+
+               div :global(.table-wrapper) {
+                       margin: 0.5rem -1rem;
+                       border-radius: 0;
+                       border-left: none;
+                       border-right: none;
+               }
+       }
+
+       /* Dark mode adjustments */
+       @media (prefers-color-scheme: dark) {
+               div :global(blockquote:hover) {
+                       background: var(--muted);
+               }
+       }
+
+       /* Image load error fallback */
+       div :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);
+       }
+
+       div :global(.image-error-content) {
+               display: flex;
+               flex-direction: column;
+               align-items: center;
+               gap: 0.75rem;
+               color: var(--muted-foreground);
+               text-align: center;
+       }
+
+       div :global(.image-error-content svg) {
+               opacity: 0.5;
+       }
+
+       div :global(.image-error-text) {
+               font-size: 0.875rem;
+       }
+
+       div :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;
+       }
+
+       div :global(.image-error-link:hover) {
+               background: var(--muted);
+               border-color: var(--primary);
+       }
+</style>
diff --git a/tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/server/webui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
new file mode 100644 (file)
index 0000000..625fdc7
--- /dev/null
@@ -0,0 +1,95 @@
+<script lang="ts">
+       import hljs from 'highlight.js';
+       import { browser } from '$app/environment';
+       import { mode } from 'mode-watcher';
+
+       import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
+       import githubLightCss from 'highlight.js/styles/github.css?inline';
+
+       interface Props {
+               code: string;
+               language?: string;
+               class?: string;
+               maxHeight?: string;
+               maxWidth?: string;
+       }
+
+       let {
+               code,
+               language = 'text',
+               class: className = '',
+               maxHeight = '60vh',
+               maxWidth = ''
+       }: Props = $props();
+
+       let highlightedHtml = $state('');
+
+       function loadHighlightTheme(isDark: boolean) {
+               if (!browser) return;
+
+               const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
+               existingThemes.forEach((style) => style.remove());
+
+               const style = document.createElement('style');
+               style.setAttribute('data-highlight-theme-preview', 'true');
+               style.textContent = isDark ? githubDarkCss : githubLightCss;
+
+               document.head.appendChild(style);
+       }
+
+       $effect(() => {
+               const currentMode = mode.current;
+               const isDark = currentMode === 'dark';
+
+               loadHighlightTheme(isDark);
+       });
+
+       $effect(() => {
+               if (!code) {
+                       highlightedHtml = '';
+                       return;
+               }
+
+               try {
+                       // Check if the language is supported
+                       const lang = language.toLowerCase();
+                       const isSupported = hljs.getLanguage(lang);
+
+                       if (isSupported) {
+                               const result = hljs.highlight(code, { language: lang });
+                               highlightedHtml = result.value;
+                       } else {
+                               // Try auto-detection or fallback to plain text
+                               const result = hljs.highlightAuto(code);
+                               highlightedHtml = result.value;
+                       }
+               } catch {
+                       // Fallback to escaped plain text
+                       highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+               }
+       });
+</script>
+
+<div
+       class="code-preview-wrapper rounded-lg border border-border bg-muted {className}"
+       style="max-height: {maxHeight}; max-width: {maxWidth};"
+>
+       <!-- Needs to be formatted as single line for proper rendering -->
+       <pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
+</div>
+
+<style>
+       .code-preview-wrapper {
+               font-family:
+                       ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
+                       'Liberation Mono', Menlo, monospace;
+       }
+
+       .code-preview-wrapper pre {
+               background: transparent;
+       }
+
+       .code-preview-wrapper code {
+               background: transparent;
+       }
+</style>
diff --git a/tools/server/webui/src/lib/components/app/content/index.ts b/tools/server/webui/src/lib/components/app/content/index.ts
new file mode 100644 (file)
index 0000000..bca1c9f
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ *
+ * CONTENT RENDERING
+ *
+ * Components for rendering rich content: markdown, code, and previews.
+ *
+ */
+
+/**
+ * **MarkdownContent** - Rich markdown renderer
+ *
+ * Renders markdown content with syntax highlighting, LaTeX math,
+ * tables, links, and code blocks. Optimized for streaming with
+ * incremental block-based rendering.
+ *
+ * **Features:**
+ * - GFM (GitHub Flavored Markdown): tables, task lists, strikethrough
+ * - LaTeX math via KaTeX (`$inline$` and `$$block$$`)
+ * - Syntax highlighting (highlight.js) with language detection
+ * - Code copy buttons with click feedback
+ * - External links open in new tab with security attrs
+ * - Image attachment resolution from message extras
+ * - Dark/light theme support (auto-switching)
+ * - Streaming-optimized incremental rendering
+ * - Code preview dialog for large blocks
+ *
+ * @example
+ * ```svelte
+ * <MarkdownContent content={message.content} attachments={message.extra} />
+ * ```
+ */
+export { default as MarkdownContent } from './MarkdownContent.svelte';
+
+/**
+ * **SyntaxHighlightedCode** - Code syntax highlighting
+ *
+ * Renders code with syntax highlighting using highlight.js.
+ * Supports theme switching and scrollable containers.
+ *
+ * **Features:**
+ * - Auto language detection with fallback
+ * - Dark/light theme auto-switching
+ * - Scrollable container with configurable max dimensions
+ * - Monospace font styling
+ * - Preserves whitespace and formatting
+ *
+ * @example
+ * ```svelte
+ * <SyntaxHighlightedCode code={jsonString} language="json" />
+ * ```
+ */
+export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte';
+
+/**
+ * **CollapsibleContentBlock** - Expandable content card
+ *
+ * Reusable collapsible card with header, icon, and auto-scroll.
+ * Used for tool calls and reasoning blocks in chat messages.
+ *
+ * **Features:**
+ * - Collapsible content with smooth animation
+ * - Custom icon and title display
+ * - Optional subtitle/status text
+ * - Auto-scroll during streaming (pauses on user scroll)
+ * - Configurable max height with overflow scroll
+ *
+ * @example
+ * ```svelte
+ * <CollapsibleContentBlock
+ *   bind:open
+ *   icon={BrainIcon}
+ *   title="Thinking..."
+ *   isStreaming={true}
+ * >
+ *   {reasoningContent}
+ * </CollapsibleContentBlock>
+ * ```
+ */
+export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
index e2095e0876531d7dedfb7d12df9b42f3e7332929..21412f47e5a50d1199ebb9834c06756e2e1ada0d 100644 (file)
        let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
 
        let searchQuery = $state('');
-       let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
+       let selectedIds = $state.raw<SvelteSet<string>>(getInitialSelectedIds());
        let lastClickedId = $state<string | null>(null);
 
+       function getInitialSelectedIds(): SvelteSet<string> {
+               return new SvelteSet(conversations.map((c) => c.id));
+       }
+
        let filteredConversations = $derived(
                conversations.filter((conv) => {
                        const name = conv.name || 'Untitled conversation';
@@ -92,7 +96,7 @@
        }
 
        function handleCancel() {
-               selectedIds = new SvelteSet(conversations.map((c) => c.id));
+               selectedIds = getInitialSelectedIds();
                searchQuery = '';
                lastClickedId = null;
 
        }
 
        export function reset() {
-               selectedIds = new SvelteSet(conversations.map((c) => c.id));
+               selectedIds = getInitialSelectedIds();
                searchQuery = '';
                lastClickedId = null;
        }
diff --git a/tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte b/tools/server/webui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte
new file mode 100644 (file)
index 0000000..e302f83
--- /dev/null
@@ -0,0 +1,93 @@
+<script lang="ts">
+       import { ChevronLeft, ChevronRight } from '@lucide/svelte';
+
+       interface Props {
+               class?: string;
+               children?: import('svelte').Snippet;
+               gapSize?: string;
+               onScrollableChange?: (isScrollable: boolean) => void;
+       }
+
+       let { class: className = '', children, gapSize = '3', onScrollableChange }: Props = $props();
+
+       let canScrollLeft = $state(false);
+       let canScrollRight = $state(false);
+       let scrollContainer: HTMLDivElement | undefined = $state();
+
+       function scrollLeft(event?: MouseEvent) {
+               event?.stopPropagation();
+               event?.preventDefault();
+
+               if (!scrollContainer) return;
+
+               scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
+       }
+
+       function scrollRight(event?: MouseEvent) {
+               event?.stopPropagation();
+               event?.preventDefault();
+
+               if (!scrollContainer) return;
+
+               scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
+       }
+
+       function updateScrollButtons() {
+               if (!scrollContainer) return;
+
+               const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+
+               canScrollLeft = scrollLeft > 0;
+               canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
+
+               const isScrollable = scrollWidth > clientWidth;
+               onScrollableChange?.(isScrollable);
+       }
+
+       export function resetScroll() {
+               if (scrollContainer) {
+                       scrollContainer.scrollLeft = 0;
+                       setTimeout(() => {
+                               updateScrollButtons();
+                       }, 0);
+               }
+       }
+
+       $effect(() => {
+               if (scrollContainer) {
+                       setTimeout(() => {
+                               updateScrollButtons();
+                       }, 0);
+               }
+       });
+</script>
+
+<div class="relative {className}">
+       <button
+               class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft
+                       ? 'opacity-100'
+                       : 'pointer-events-none opacity-0'}"
+               onclick={scrollLeft}
+               aria-label="Scroll left"
+       >
+               <ChevronLeft class="h-4 w-4" />
+       </button>
+
+       <div
+               class="scrollbar-hide flex items-start gap-{gapSize} overflow-x-auto"
+               bind:this={scrollContainer}
+               onscroll={updateScrollButtons}
+       >
+               {@render children?.()}
+       </div>
+
+       <button
+               class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
+                       ? 'opacity-100'
+                       : 'pointer-events-none opacity-0'}"
+               onclick={scrollRight}
+               aria-label="Scroll right"
+       >
+               <ChevronRight class="h-4 w-4" />
+       </button>
+</div>
index 5b7522fe1b9427b31272eb7f9b80285fbb10bcfb..da55abda0234fa6ece0e600782e4dab962e5b17a 100644 (file)
@@ -11,7 +11,9 @@
 
        let baseClasses =
                'px-1 pointer-events-none inline-flex select-none items-center gap-0.5 font-sans text-md font-medium opacity-0 transition-opacity -my-1';
-       let variantClasses = variant === 'destructive' ? 'text-destructive' : 'text-muted-foreground';
+       let variantClasses = $derived(
+               variant === 'destructive' ? 'text-destructive' : 'text-muted-foreground'
+       );
 </script>
 
 <kbd class="{baseClasses} {variantClasses} {className}">
diff --git a/tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte b/tools/server/webui/src/lib/components/app/misc/TruncatedText.svelte
new file mode 100644 (file)
index 0000000..9a8731f
--- /dev/null
@@ -0,0 +1,48 @@
+<script lang="ts">
+       import * as Tooltip from '$lib/components/ui/tooltip';
+
+       interface Props {
+               text: string;
+               class?: string;
+       }
+
+       let { text, class: className = '' }: Props = $props();
+
+       let textElement: HTMLSpanElement | undefined = $state();
+       let isTruncated = $state(false);
+
+       function checkTruncation() {
+               if (textElement) {
+                       isTruncated = textElement.scrollWidth > textElement.clientWidth;
+               }
+       }
+
+       $effect(() => {
+               if (textElement) {
+                       checkTruncation();
+
+                       const observer = new ResizeObserver(checkTruncation);
+                       observer.observe(textElement);
+
+                       return () => observer.disconnect();
+               }
+       });
+</script>
+
+{#if isTruncated}
+       <Tooltip.Root>
+               <Tooltip.Trigger class={className}>
+                       <span bind:this={textElement} class="block truncate">
+                               {text}
+                       </span>
+               </Tooltip.Trigger>
+
+               <Tooltip.Content class="z-[9999]">
+                       <p>{text}</p>
+               </Tooltip.Content>
+       </Tooltip.Root>
+{:else}
+       <span bind:this={textElement} class="{className} block truncate">
+               {text}
+       </span>
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/misc/index.ts b/tools/server/webui/src/lib/components/app/misc/index.ts
new file mode 100644 (file)
index 0000000..02bd70b
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ *
+ * MISC
+ *
+ * Miscellaneous utility components.
+ *
+ */
+
+/**
+ * **ConversationSelection** - Multi-select conversation picker
+ *
+ * List of conversations with checkboxes for multi-selection.
+ * Used in import/export dialogs for selecting conversations.
+ *
+ * **Features:**
+ * - Search/filter conversations by name
+ * - Select all / deselect all controls
+ * - Shift-click for range selection
+ * - Message count display per conversation
+ * - Mode-specific UI (export vs import)
+ */
+export { default as ConversationSelection } from './ConversationSelection.svelte';
+
+/**
+ * Horizontal scrollable carousel with navigation arrows.
+ * Used for displaying items in a horizontally scrollable container
+ * with left/right navigation buttons that appear on hover.
+ */
+export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte';
+
+/**
+ * **TruncatedText** - Text with ellipsis and tooltip
+ *
+ * Displays text with automatic truncation and full content in tooltip.
+ * Useful for long names or paths in constrained spaces.
+ */
+export { default as TruncatedText } from './TruncatedText.svelte';
+
+/**
+ * **KeyboardShortcutInfo** - Keyboard shortcut hint display
+ *
+ * Displays keyboard shortcut hints (e.g., "⌘ + Enter").
+ * Supports special keys like shift, cmd, and custom text.
+ */
+export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';
diff --git a/tools/server/webui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/server/webui/src/lib/components/app/navigation/DropdownMenuActions.svelte
new file mode 100644 (file)
index 0000000..83d856d
--- /dev/null
@@ -0,0 +1,86 @@
+<script lang="ts">
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { KeyboardShortcutInfo } from '$lib/components/app';
+       import type { Component } from 'svelte';
+
+       interface ActionItem {
+               icon: Component;
+               label: string;
+               onclick: (event: Event) => void;
+               variant?: 'default' | 'destructive';
+               disabled?: boolean;
+               shortcut?: string[];
+               separator?: boolean;
+       }
+
+       interface Props {
+               triggerIcon: Component;
+               triggerTooltip?: string;
+               triggerClass?: string;
+               actions: ActionItem[];
+               align?: 'start' | 'center' | 'end';
+               open?: boolean;
+       }
+
+       let {
+               triggerIcon,
+               triggerTooltip,
+               triggerClass = '',
+               actions,
+               align = 'end',
+               open = $bindable(false)
+       }: Props = $props();
+</script>
+
+<DropdownMenu.Root bind:open>
+       <DropdownMenu.Trigger
+               class="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground {triggerClass}"
+               onclick={(e) => e.stopPropagation()}
+       >
+               {#if triggerTooltip}
+                       <Tooltip.Root>
+                               <Tooltip.Trigger>
+                                       {@render iconComponent(triggerIcon, 'h-3 w-3')}
+                                       <span class="sr-only">{triggerTooltip}</span>
+                               </Tooltip.Trigger>
+                               <Tooltip.Content>
+                                       <p>{triggerTooltip}</p>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
+               {:else}
+                       {@render iconComponent(triggerIcon, 'h-3 w-3')}
+               {/if}
+       </DropdownMenu.Trigger>
+
+       <DropdownMenu.Content {align} class="z-[999999] w-48">
+               {#each actions as action, index (action.label)}
+                       {#if action.separator && index > 0}
+                               <DropdownMenu.Separator />
+                       {/if}
+
+                       <DropdownMenu.Item
+                               onclick={action.onclick}
+                               variant={action.variant}
+                               disabled={action.disabled}
+                               class="flex items-center justify-between hover:[&>kbd]:opacity-100"
+                       >
+                               <div class="flex items-center gap-2">
+                                       {@render iconComponent(
+                                               action.icon,
+                                               `h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
+                                       )}
+                                       {action.label}
+                               </div>
+
+                               {#if action.shortcut}
+                                       <KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+                               {/if}
+                       </DropdownMenu.Item>
+               {/each}
+       </DropdownMenu.Content>
+</DropdownMenu.Root>
+
+{#snippet iconComponent(IconComponent: Component, className: string)}
+       <IconComponent class={className} />
+{/snippet}
diff --git a/tools/server/webui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte b/tools/server/webui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte
new file mode 100644 (file)
index 0000000..3bd68d3
--- /dev/null
@@ -0,0 +1,50 @@
+<script lang="ts">
+       import type { Snippet } from 'svelte';
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+       import { SearchInput } from '$lib/components/app';
+
+       interface Props {
+               placeholder?: string;
+               searchValue?: string;
+               onSearchChange?: (value: string) => void;
+               onSearchKeyDown?: (event: KeyboardEvent) => void;
+               emptyMessage?: string;
+               isEmpty?: boolean;
+               children: Snippet;
+               footer?: Snippet;
+       }
+
+       let {
+               placeholder = 'Search...',
+               searchValue = $bindable(''),
+               onSearchChange,
+               onSearchKeyDown,
+               emptyMessage = 'No items found',
+               isEmpty = false,
+               children,
+               footer
+       }: Props = $props();
+</script>
+
+<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
+       <SearchInput
+               {placeholder}
+               bind:value={searchValue}
+               onInput={onSearchChange}
+               onKeyDown={onSearchKeyDown}
+       />
+</div>
+
+<div class="overflow-y-auto">
+       {@render children()}
+
+       {#if isEmpty}
+               <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
+       {/if}
+</div>
+
+{#if footer}
+       <DropdownMenu.Separator />
+
+       {@render footer()}
+{/if}
diff --git a/tools/server/webui/src/lib/components/app/navigation/index.ts b/tools/server/webui/src/lib/components/app/navigation/index.ts
new file mode 100644 (file)
index 0000000..051491b
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ *
+ * NAVIGATION & MENUS
+ *
+ * Components for dropdown menus and action selection.
+ *
+ */
+
+/**
+ * **DropdownMenuSearchable** - Searchable content for dropdown menus
+ *
+ * Renders a search input with filtered content area, empty state, and optional footer.
+ * Designed to be injected into any dropdown container (DropdownMenu.Content,
+ * DropdownMenu.SubContent, etc.) without providing its own Root.
+ *
+ * **Features:**
+ * - Search/filter input
+ * - Keyboard navigation support
+ * - Custom content and footer via snippets
+ * - Empty state message
+ *
+ * @example
+ * ```svelte
+ * <DropdownMenu.Root>
+ *   <DropdownMenu.Trigger>...</DropdownMenu.Trigger>
+ *   <DropdownMenu.Content class="pt-0">
+ *     <DropdownMenuSearchable
+ *       bind:searchValue
+ *       placeholder="Search..."
+ *       isEmpty={filteredItems.length === 0}
+ *     >
+ *       {#each items as item}<Item {item} />{/each}
+ *     </DropdownMenuSearchable>
+ *   </DropdownMenu.Content>
+ * </DropdownMenu.Root>
+ * ```
+ */
+export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svelte';
+
+/**
+ * **DropdownMenuActions** - Multi-action dropdown menu
+ *
+ * Dropdown menu for multiple action options with icons and shortcuts.
+ * Supports destructive variants and keyboard shortcut hints.
+ *
+ * **Features:**
+ * - Configurable trigger icon with tooltip
+ * - Action items with icons and labels
+ * - Destructive variant styling
+ * - Keyboard shortcut display
+ * - Separator support between groups
+ *
+ * @example
+ * ```svelte
+ * <DropdownMenuActions
+ *   triggerIcon={MoreHorizontal}
+ *   triggerTooltip="More actions"
+ *   actions={[
+ *     { icon: Edit, label: 'Edit', onclick: handleEdit },
+ *     { icon: Trash, label: 'Delete', onclick: handleDelete, variant: 'destructive' }
+ *   ]}
+ * />
+ * ```
+ */
+export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
index fa4c2842ccd614a46af02b89d55bfe80a950388f..520e5bf56fa37a3d8717b2d51bf82c1cbc1c3f7f 100644 (file)
@@ -8,6 +8,7 @@
        import { serverStore, serverLoading } from '$lib/stores/server.svelte';
        import { config, settingsStore } from '$lib/stores/settings.svelte';
        import { fade, fly, scale } from 'svelte/transition';
+       import { KeyboardKey } from '$lib/enums/keyboard';
 
        interface Props {
                class?: string;
        }
 
        function handleApiKeyKeydown(event: KeyboardEvent) {
-               if (event.key === 'Enter') {
+               if (event.key === KeyboardKey.ENTER) {
                        handleSaveApiKey();
                }
        }
index d9f6d4a32a40867c6ad0df29723f679558406c82..86a962de123fa89aaba29a9e3e57a7eff57c911a 100644 (file)
@@ -48,7 +48,7 @@
                        {model || 'Unknown Model'}
                </Badge>
 
-               {#if serverData.default_generation_settings.n_ctx}
+               {#if serverData?.default_generation_settings?.n_ctx}
                        <Badge variant="secondary" class="text-xs">
                                ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()}
                        </Badge>
diff --git a/tools/server/webui/src/lib/components/app/server/index.ts b/tools/server/webui/src/lib/components/app/server/index.ts
new file mode 100644 (file)
index 0000000..39ac5b4
--- /dev/null
@@ -0,0 +1,80 @@
+/**
+ *
+ * SERVER
+ *
+ * Components for displaying server connection state and handling
+ * connection errors. Integrates with serverStore for state management.
+ *
+ */
+
+/**
+ * **ServerStatus** - Server connection status indicator
+ *
+ * Compact status display showing connection state, model name,
+ * and context size. Used in headers and loading screens.
+ *
+ * **Architecture:**
+ * - Reads state from serverStore (props, loading, error)
+ * - Displays model name from modelsStore
+ *
+ * **Features:**
+ * - Status dot: green (connected), yellow (connecting), red (error), gray (unknown)
+ * - Status text label
+ * - Model name badge with icon
+ * - Context size badge
+ * - Optional error action button
+ *
+ * @example
+ * ```svelte
+ * <ServerStatus showActions />
+ * ```
+ */
+export { default as ServerStatus } from './ServerStatus.svelte';
+
+/**
+ * **ServerErrorSplash** - Full-screen connection error display
+ *
+ * Blocking error screen shown when server connection fails.
+ * Provides retry options and API key input for authentication errors.
+ *
+ * **Architecture:**
+ * - Detects access denied errors for API key flow
+ * - Validates API key against server before saving
+ * - Integrates with settingsStore for API key persistence
+ *
+ * **Features:**
+ * - Error message display with icon
+ * - Retry connection button with loading state
+ * - API key input for authentication errors
+ * - API key validation with success/error feedback
+ * - Troubleshooting section with server start commands
+ * - Animated transitions for UI elements
+ *
+ * @example
+ * ```svelte
+ * <ServerErrorSplash
+ *   error={serverError}
+ *   onRetry={handleRetry}
+ *   showTroubleshooting
+ * />
+ * ```
+ */
+export { default as ServerErrorSplash } from './ServerErrorSplash.svelte';
+
+/**
+ * **ServerLoadingSplash** - Full-screen loading display
+ *
+ * Shown during initial server connection. Displays loading animation
+ * with ServerStatus component for real-time connection state.
+ *
+ * **Features:**
+ * - Animated server icon
+ * - Customizable loading message
+ * - Embedded ServerStatus for live updates
+ *
+ * @example
+ * ```svelte
+ * <ServerLoadingSplash message="Connecting to server..." />
+ * ```
+ */
+export { default as ServerLoadingSplash } from './ServerLoadingSplash.svelte';
index 4d15145493724019a3d14844dc1e5acad31a8252..c3e6ac0720e6ee02f3928cc8e57bc3dc6121646f 100644 (file)
@@ -42,7 +42,7 @@
        bind:this={ref}
        data-slot="badge"
        {href}
-       class={cn(badgeVariants({ variant }), className)}
+       class={cn(badgeVariants({ variant }), className, 'backdrop-blur-sm')}
        {...restProps}
 >
        {@render children?.()}
index d12c8de147730628aeb70eafcc961ed34902b163..d29358c8e017703de14e7a6dce7837d232dde74d 100644 (file)
@@ -12,8 +12,9 @@
                                        'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
                                outline:
                                        'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
-                               secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
-                               ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+                               secondary:
+                                       'dark:bg-secondary dark:text-secondary-foreground bg-background shadow-sm text-foreground hover:bg-muted-foreground/20',
+                               ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10',
                                link: 'text-primary underline-offset-4 hover:underline'
                        },
                        size: {
index c40d14309f9cef6b35e163684dfed478c02f6a8b..b9dcd2de6f4cdb9d4aecc0ed1f83c96802dcba52 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import type { HTMLAttributes } from 'svelte/elements';
        import { cn, type WithElementRef } from '$lib/components/ui/utils';
+       import { BOX_BORDER } from '$lib/constants/css-classes';
 
        let {
                ref = $bindable(null),
@@ -14,7 +15,8 @@
        bind:this={ref}
        data-slot="card"
        class={cn(
-               'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
+               'flex flex-col gap-6 rounded-xl bg-card py-6 text-card-foreground shadow-sm',
+               BOX_BORDER,
                className
        )}
        {...restProps}
index 869c38e848aa42cf23a245d62bd0a9203d3a41c8..6013ca2661b506a72e2f8d6547abc7500c511512 100644 (file)
@@ -19,7 +19,7 @@
                data-slot="dropdown-menu-content"
                {sideOffset}
                class={cn(
-                       'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
+                       'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
                        className
                )}
                {...restProps}
index 889b720716ed69b47bfe943f0d287779874be5cf..2b6279b6420de0b5efe201d9566fc8ef0267abb1 100644 (file)
@@ -44,6 +44,7 @@
                        'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
                        className
                )}
+               style="backdrop-filter: blur(0.5rem);"
                {type}
                bind:value
                {...restProps}
index 29d3a9c43a347d7bf5bfc721774543961ad7a797..0d5baf6d6d4d07b578a17ff2f1fe64f82a254f26 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
        import { Button } from '$lib/components/ui/button/index.js';
-       import { cn } from '$lib/components/ui/utils.js';
        import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
        import type { ComponentProps } from 'svelte';
        import { useSidebar } from './context.svelte.js';
@@ -22,7 +21,7 @@
        data-slot="sidebar-trigger"
        variant="ghost"
        size="icon"
-       class={cn('size-7', className)}
+       class="rounded-full backdrop-blur-lg {className} h-9! w-9!"
        type="button"
        onclick={(e) => {
                onclick?.(e);
index 5a5975e137ea58affacc7e48ad240f14628204b9..e0848790d3c15cd04f8ccecdd53c96a4c74b2431 100644 (file)
@@ -15,7 +15,7 @@
        bind:checked
        data-slot="switch"
        class={cn(
-               'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
+               'peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
                className
        )}
        {...restProps}
index 72ea93a01015308d3b80ac5f42b34a47d3d0d1ab..011d492f3d8d4b1f3726116ad9df89aae6f6ebaa 100644 (file)
@@ -9,22 +9,28 @@
                side = 'top',
                children,
                arrowClasses,
+               noPortal = false,
                ...restProps
        }: TooltipPrimitive.ContentProps & {
                arrowClasses?: string;
+               noPortal?: boolean;
        } = $props();
+
+       const contentClass = $derived(
+               cn(
+                       'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
+                       className
+               )
+       );
 </script>
 
-<TooltipPrimitive.Portal>
+{#snippet tooltipContent()}
        <TooltipPrimitive.Content
                bind:ref
                data-slot="tooltip-content"
                {sideOffset}
                {side}
-               class={cn(
-                       'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
-                       className
-               )}
+               class={contentClass}
                {...restProps}
        >
                {@render children?.()}
                        {/snippet}
                </TooltipPrimitive.Arrow>
        </TooltipPrimitive.Content>
-</TooltipPrimitive.Portal>
+{/snippet}
+
+{#if noPortal}
+       {@render tooltipContent()}
+{:else}
+       <TooltipPrimitive.Portal>
+               {@render tooltipContent()}
+       </TooltipPrimitive.Portal>
+{/if}
index 8081ef2ec2559149cc71326988194eca37fa0635..28757a966ff5bb0f9fca71149b4c662bc431cd2e 100644 (file)
@@ -48,8 +48,7 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
        const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
        const headers = { ...baseHeaders, ...customHeaders };
 
-       const url =
-               path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
+       const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
 
        const response = await fetch(url, {
                ...fetchOptions,
index 38e809f2d3cf4b282464c7ec28ab412a1fc004ac..5eb2bbaea1e9e24e21311d91b64f7cca1ba6bebd 100644 (file)
@@ -93,3 +93,6 @@ export { getLanguageFromFilename } from './syntax-highlight-language';
 
 // Text file utilities
 export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
+
+// Image error fallback utilities
+export { getImageErrorFallbackHtml } from './image-error-fallback';