refactor: Components

This commit is contained in:
Aleksander Grygier 2026-01-27 10:26:14 +01:00
parent d4bd13245d
commit 0e8a4ccfee
26 changed files with 287 additions and 2082 deletions

View File

@ -6,14 +6,14 @@
*
*/
/** Styled icon button for action triggers with tooltip. */
export { default as ActionIcon } from './ActionIcon.svelte';
/** Styled button for action triggers with icon support. */
export { default as ActionButton } from './ActionButton.svelte';
/** Code block actions component (copy, preview). */
export { default as ActionIconsCodeBlock } from './ActionIconsCodeBlock.svelte';
/** Copy-to-clipboard button with success feedback. */
export { default as CopyToClipboardIcon } from './CopyToClipboardIcon.svelte';
/** Copy-to-clipboard icon button with click handler. */
export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
/** Remove/delete button with X icon. */
export { default as RemoveButton } from './RemoveButton.svelte';
/** Remove/delete icon button with X icon. */
export { default as ActionIconRemove } from './ActionIconRemove.svelte';
/** Display for keyboard shortcut hints (e.g., "⌘ + Enter"). */
export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';

View File

@ -0,0 +1,54 @@
<script lang="ts">
import { Copy, Eye } from '@lucide/svelte';
import { copyCodeToClipboard } from '$lib/utils';
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() === 'html');
function handleCopy() {
if (disabled) return;
copyCodeToClipboard(code);
}
function handlePreview() {
if (disabled) return;
onPreview?.(code, language);
}
</script>
<div class="code-block-actions">
<button
class="copy-code-btn"
class:opacity-50={disabled}
class:!cursor-not-allowed={disabled}
title={disabled ? 'Code incomplete' : 'Copy code'}
aria-label="Copy code"
aria-disabled={disabled}
type="button"
onclick={handleCopy}
>
<Copy size={16} />
</button>
{#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>

View File

@ -1,4 +1,13 @@
<script lang="ts">
/**
* CollapsibleInfoCard - Reusable collapsible card component
*
* Used for displaying thinking content, tool calls, and other collapsible information
* with a consistent UI pattern.
*
* Features auto-scroll during streaming: scrolls to bottom automatically,
* stops when user scrolls up, resumes when user scrolls back to bottom.
*/
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';
@ -62,9 +71,7 @@
{#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}
@ -78,7 +85,6 @@
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />
<span class="sr-only">Toggle content</span>
</div>
</Collapsible.Trigger>
@ -88,7 +94,7 @@
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);"
style="max-height: var(--max-message-height);"
>
{@render children()}
</div>

View File

@ -11,19 +11,12 @@
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 { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
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,
@ -33,13 +26,13 @@
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 { DialogCodePreview } from '$lib/components/app/dialogs';
import CodeBlockActions from './CodeBlockActions.svelte';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import type { DatabaseMessage } from '$lib/types/database';
interface Props {
attachments?: DatabaseMessageExtra[];
message?: DatabaseMessage;
content: string;
class?: string;
disableMath?: boolean;
@ -48,10 +41,9 @@
interface MarkdownBlock {
id: string;
html: string;
contentHash?: string;
}
let { content, attachments, class: className = '', disableMath = false }: Props = $props();
let { content, message, class: className = '', disableMath = false }: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
@ -68,15 +60,10 @@
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;
void message;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
@ -94,12 +81,11 @@
}
return proc
.use(rehypeHighlight, {
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }
}) // Add syntax highlighting
.use(rehypeHighlight) // 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(rehypeResolveAttachmentImages, { message })
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
});
@ -196,61 +182,6 @@
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.
@ -326,16 +257,10 @@
* @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;
}
@ -350,37 +275,23 @@
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
const processorInstance = processor();
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const processedRoot = (await processorInstance.run(ast)) as HastRoot;
const processedChildren = processedRoot.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];
for (let index = 0; index < processedChildren.length; index++) {
const hastChild = processedChildren[index];
const id = getHastNodeId(hastChild, index);
const existing = renderedBlocks[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;
}
if (existing && existing.id === id) {
nextBlocks.push(existing);
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 });
const html = stringifyProcessedNode(processorInstance, processedRoot, hastChild);
nextBlocks.push({ id, html });
}
renderedBlocks = nextBlocks;
@ -388,10 +299,8 @@
renderedBlocks = [];
}
previousContent = prefixMarkdown;
unstableBlockHtml = '';
incompleteCodeBlock = incompleteBlock;
return;
}
@ -401,52 +310,38 @@
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 processedRoot = (await processorInstance.run(ast)) as HastRoot;
const processedChildren = processedRoot.children ?? [];
const stableCount = Math.max(processedChildren.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];
const hastChild = processedChildren[index];
const id = getHastNodeId(hastChild, index);
const existing = renderedBlocks[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;
}
if (existing && existing.id === id) {
nextBlocks.push(existing);
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
const html = stringifyProcessedNode(
processorInstance,
processedRoot,
processedChildren[index]
);
nextBlocks.push({ id, html, contentHash: hash });
nextBlocks.push({ id, html });
}
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);
if (processedChildren.length > stableCount) {
const unstableChild = processedChildren[stableCount];
unstableHtml = stringifyProcessedNode(processorInstance, processedRoot, unstableChild);
}
renderedBlocks = nextBlocks;
previousContent = markdown;
await tick(); // Force DOM sync before updating unstable HTML block
unstableBlockHtml = unstableHtml;
}
@ -483,10 +378,10 @@
function setupImageErrorHandlers() {
if (!containerRef) return;
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
const images = containerRef.querySelectorAll<HTMLImageElement>('img:not([data-error-bound])');
for (const img of images) {
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
img.dataset.errorBound = 'true';
img.addEventListener('error', handleImageError);
}
}
@ -500,9 +395,8 @@
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;
if (img.src.startsWith('data:') || img.dataset.errorHandled === 'true') return;
img.dataset.errorHandled = 'true';
const src = img.src;
// Create fallback element
@ -514,10 +408,30 @@
img.parentNode?.replaceChild(fallback, img);
}
/**
* Converts a single HAST node to an enhanced HTML string.
* Applies link and code block enhancements to the output.
* @param processorInstance - The remark/rehype processor instance
* @param processedRoot - The full processed HAST root (for context)
* @param child - The specific HAST child node to stringify
* @returns Enhanced HTML string representation of the node
*/
function stringifyProcessedNode(
processorInstance: ReturnType<typeof processor>,
processedRoot: HastRoot,
child: unknown
) {
const root: HastRoot = {
...(processedRoot as HastRoot),
children: [child as never]
};
return processorInstance.stringify(root);
}
/**
* 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) {
@ -535,12 +449,6 @@
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);
@ -607,11 +515,11 @@
<div class="code-block-wrapper streaming-code-block relative">
<div class="code-block-header">
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
<ActionIconsCodeBlock
<CodeBlockActions
code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'}
disabled={true}
onPreview={(code: string, lang: string) => {
onPreview={(code, lang) => {
previewCode = code;
previewLanguage = lang;
previewDialogOpen = true;
@ -890,7 +798,6 @@
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);
}
@ -901,7 +808,6 @@
/* 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;
@ -1008,7 +914,7 @@
/* Disable hover effects when rendering user messages */
.markdown-user-content :global(a),
.markdown-user-content :global(a:hover) {
color: inherit;
color: var(--primary-foreground);
}
.markdown-user-content :global(table:hover) {

View File

@ -26,7 +26,7 @@
*
* @example
* ```svelte
* <MarkdownContent content={message.content} attachments={message.extra} />
* <MarkdownContent content={message.content} {message} />
* ```
*/
export { default as MarkdownContent } from './MarkdownContent.svelte';
@ -77,3 +77,6 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
* ```
*/
export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
/** Code block actions component (copy, preview). */
export { default as CodeBlockActions } from './CodeBlockActions.svelte';

View File

@ -136,6 +136,44 @@ export { default as DialogConfirmation } from './DialogConfirmation.svelte';
*/
export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte';
/**
*
* CONTENT PREVIEW DIALOGS
*
* Dialogs for previewing and displaying content in full-screen or modal views.
*
*/
/**
* **DialogCodePreview** - Full-screen code/HTML preview
*
* Full-screen dialog for previewing HTML or code in an isolated iframe.
* Used by MarkdownContent component for previewing rendered HTML blocks
* from code blocks in chat messages.
*
* **Architecture:**
* - Uses ShadCN Dialog with full viewport layout
* - Sandboxed iframe execution (allow-scripts only)
* - Clears content when closed for security
*
* **Features:**
* - Full viewport iframe preview
* - Sandboxed execution environment
* - Close button with mix-blend-difference for visibility over any content
* - Automatic content cleanup on close
* - Supports HTML preview with proper isolation
*
* @example
* ```svelte
* <DialogCodePreview
* bind:open={showPreview}
* code={htmlContent}
* language="html"
* />
* ```
*/
export { default as DialogCodePreview } from './DialogCodePreview.svelte';
/**
*
* ATTACHMENT DIALOGS

View File

@ -0,0 +1,30 @@
/**
*
* FORMS & INPUTS
*
* Form-related utility components.
*
*/
/**
* **SearchInput** - Search field with clear button
*
* Input field optimized for search with clear button and keyboard handling.
* Supports placeholder, autofocus, and change callbacks.
*/
export { default as SearchInput } from './SearchInput.svelte';
/**
* **KeyValuePairs** - Editable key-value list
*
* Dynamic list of key-value pairs with add/remove functionality.
* Used for HTTP headers, metadata, and configuration.
*
* **Features:**
* - Add new pairs with button
* - Remove individual pairs
* - Customizable placeholders and labels
* - Empty state message
* - Auto-resize value textarea
*/
export { default as KeyValuePairs } from './KeyValuePairs.svelte';

View File

@ -1,6 +1,11 @@
export * from './actions';
export * from './badges';
export * from './chat';
export * from './content';
export * from './dialogs';
export * from './forms';
export * from './mcp';
export * from './misc';
export * from './models';
export * from './navigation';
export * from './server';

View File

@ -1,44 +0,0 @@
<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}

View File

@ -1,27 +0,0 @@
<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>

View File

@ -1,39 +0,0 @@
<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}

View File

@ -1,103 +0,0 @@
<script lang="ts">
/**
* CollapsibleInfoCard - Reusable collapsible card component
*
* Used for displaying thinking content, tool calls, and other collapsible information
* with a consistent UI pattern.
*
* Features auto-scroll during streaming: scrolls to bottom automatically,
* stops when user scrolls up, resumes when user scrolls back to bottom.
*/
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="max-height: var(--max-message-height);"
>
{@render children()}
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>

View File

@ -1,86 +0,0 @@
<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}

View File

@ -1,88 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { cn } from '$lib/components/ui/utils';
import { SearchInput } from '$lib/components/app';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
onSearchKeyDown?: (event: KeyboardEvent) => void;
align?: 'start' | 'center' | 'end';
contentClass?: string;
emptyMessage?: string;
isEmpty?: boolean;
disabled?: boolean;
trigger: Snippet;
children: Snippet;
footer?: Snippet;
}
let {
open = $bindable(false),
onOpenChange,
placeholder = 'Search...',
searchValue = $bindable(''),
onSearchChange,
onSearchKeyDown,
align = 'start',
contentClass = 'w-72',
emptyMessage = 'No items found',
isEmpty = false,
disabled = false,
trigger,
children,
footer
}: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
if (!newOpen) {
searchValue = '';
onSearchChange?.('');
}
onOpenChange?.(newOpen);
}
</script>
<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger
{disabled}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{@render trigger()}
</DropdownMenu.Trigger>
<DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
<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={cn('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}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@ -1,95 +0,0 @@
<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>

View File

@ -2,113 +2,10 @@
*
* MISC
*
* Reusable utility components used across the application.
* Includes content rendering, UI primitives, and helper components.
* Miscellaneous utility components.
*
*/
/**
*
* 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} {message} />
* ```
*/
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';
/**
* **CodePreviewDialog** - Full-screen code preview
*
* Full-screen dialog for previewing HTML/code in an isolated iframe.
* Used by MarkdownContent for previewing rendered HTML blocks.
*
* **Features:**
* - Full viewport iframe preview
* - Sandboxed execution (allow-scripts only)
* - Close button with mix-blend-difference for visibility
* - Clears content when closed for security
*/
export { default as CodePreviewDialog } from './CodePreviewDialog.svelte';
/**
*
* COLLAPSIBLE & EXPANDABLE
*
* Components for showing/hiding content sections.
*
*/
/**
* **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';
/**
* **TruncatedText** - Text with ellipsis and tooltip
*
@ -117,135 +14,6 @@ export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.sv
*/
export { default as TruncatedText } from './TruncatedText.svelte';
/**
*
* DROPDOWNS & MENUS
*
* Components for dropdown menus and action selection.
*
*/
/**
* **DropdownMenuSearchable** - Filterable dropdown menu
*
* Generic dropdown with search input for filtering options.
* Uses Svelte snippets for flexible content rendering.
*
* **Features:**
* - Search/filter input with clear on close
* - Keyboard navigation support
* - Custom trigger, content, and footer via snippets
* - Empty state message
* - Disabled state
* - Configurable alignment and width
*
* @example
* ```svelte
* <DropdownMenuSearchable
* bind:open
* bind:searchValue
* placeholder="Search..."
* isEmpty={filteredItems.length === 0}
* >
* {#snippet trigger()}<Button>Select</Button>{/snippet}
* {#snippet children()}{#each items as item}<Item {item} />{/each}{/snippet}
* </DropdownMenuSearchable>
* ```
*/
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';
/**
*
* BUTTONS & ACTIONS
*
* Small interactive components for user actions.
*
*/
/** Styled button for action triggers with icon support. */
export { default as ActionButton } from './ActionButton.svelte';
/** Copy-to-clipboard button with success feedback. */
export { default as CopyToClipboardIcon } from './CopyToClipboardIcon.svelte';
/** Remove/delete button with X icon. */
export { default as RemoveButton } from './RemoveButton.svelte';
/**
*
* 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';
/**
*
* FORMS & INPUTS
*
* Form-related utility components.
*
*/
/**
* **SearchInput** - Search field with clear button
*
* Input field optimized for search with clear button and keyboard handling.
* Supports placeholder, autofocus, and change callbacks.
*/
export { default as SearchInput } from './SearchInput.svelte';
/**
* **KeyValuePairs** - Editable key-value list
*
* Dynamic list of key-value pairs with add/remove functionality.
* Used for HTTP headers, metadata, and configuration.
*
* **Features:**
* - Add new pairs with button
* - Remove individual pairs
* - Customizable placeholders and labels
* - Empty state message
* - Auto-resize value textarea
*/
export { default as KeyValuePairs } from './KeyValuePairs.svelte';
/**
* **ConversationSelection** - Multi-select conversation picker
*
@ -260,14 +28,3 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
* - Mode-specific UI (export vs import)
*/
export { default as ConversationSelection } from './ConversationSelection.svelte';
/**
*
* KEYBOARD & SHORTCUTS
*
* Components for displaying keyboard shortcuts.
*
*/
/** Display for keyboard shortcut hints (e.g., "⌘ + Enter"). */
export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';

View File

@ -1,50 +1,88 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { cn } from '$lib/components/ui/utils';
import { SearchInput } from '$lib/components/app';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
onSearchKeyDown?: (event: KeyboardEvent) => void;
align?: 'start' | 'center' | 'end';
contentClass?: string;
emptyMessage?: string;
isEmpty?: boolean;
disabled?: boolean;
trigger: Snippet;
children: Snippet;
footer?: Snippet;
}
let {
open = $bindable(false),
onOpenChange,
placeholder = 'Search...',
searchValue = $bindable(''),
onSearchChange,
onSearchKeyDown,
align = 'start',
contentClass = 'w-72',
emptyMessage = 'No items found',
isEmpty = false,
disabled = false,
trigger,
children,
footer
}: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
if (!newOpen) {
searchValue = '';
onSearchChange?.('');
}
onOpenChange?.(newOpen);
}
</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>
<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger
{disabled}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{@render trigger()}
</DropdownMenu.Trigger>
<div class="overflow-y-auto">
{@render children()}
<DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
<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>
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
<div class={cn('overflow-y-auto')}>
{@render children()}
{#if footer}
<DropdownMenu.Separator />
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
{@render footer()}
{/if}
{#if footer}
<DropdownMenu.Separator />
{@render footer()}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@ -7,32 +7,30 @@
*/
/**
* **DropdownMenuSearchable** - Searchable content for dropdown menus
* **DropdownMenuSearchable** - Filterable dropdown menu
*
* 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.
* Generic dropdown with search input for filtering options.
* Uses Svelte snippets for flexible content rendering.
*
* **Features:**
* - Search/filter input
* - Search/filter input with clear on close
* - Keyboard navigation support
* - Custom content and footer via snippets
* - Custom trigger, content, and footer via snippets
* - Empty state message
* - Disabled state
* - Configurable alignment and width
*
* @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>
* <DropdownMenuSearchable
* bind:open
* bind:searchValue
* placeholder="Search..."
* isEmpty={filteredItems.length === 0}
* >
* {#snippet trigger()}<Button>Select</Button>{/snippet}
* {#snippet children()}{#each items as item}<Item {item} />{/each}{/snippet}
* </DropdownMenuSearchable>
* ```
*/
export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svelte';