(webui) REFACTOR: UI primitives and polish (#19551)
* webui: UI primitives and polish (non-MCP) * chore: update webui build output
This commit is contained in:
parent
38adc7d469
commit
f486ce9f30
Binary file not shown.
|
|
@ -14,11 +14,11 @@
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 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);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 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);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.875 0 0);
|
--border: oklch(0.875 0 0);
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 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);
|
--code-foreground: oklch(0.145 0 0);
|
||||||
--layer-popover: 1000000;
|
--layer-popover: 1000000;
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 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);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
|
@ -116,12 +116,62 @@
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -17,9 +17,13 @@
|
||||||
let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
|
let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
|
||||||
|
|
||||||
let searchQuery = $state('');
|
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);
|
let lastClickedId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function getInitialSelectedIds(): SvelteSet<string> {
|
||||||
|
return new SvelteSet(conversations.map((c) => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
let filteredConversations = $derived(
|
let filteredConversations = $derived(
|
||||||
conversations.filter((conv) => {
|
conversations.filter((conv) => {
|
||||||
const name = conv.name || 'Untitled conversation';
|
const name = conv.name || 'Untitled conversation';
|
||||||
|
|
@ -92,7 +96,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
selectedIds = new SvelteSet(conversations.map((c) => c.id));
|
selectedIds = getInitialSelectedIds();
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
lastClickedId = null;
|
lastClickedId = null;
|
||||||
|
|
||||||
|
|
@ -100,7 +104,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reset() {
|
export function reset() {
|
||||||
selectedIds = new SvelteSet(conversations.map((c) => c.id));
|
selectedIds = getInitialSelectedIds();
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
lastClickedId = null;
|
lastClickedId = 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>
|
||||||
|
|
@ -11,7 +11,9 @@
|
||||||
|
|
||||||
let baseClasses =
|
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';
|
'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>
|
</script>
|
||||||
|
|
||||||
<kbd class="{baseClasses} {variantClasses} {className}">
|
<kbd class="{baseClasses} {variantClasses} {className}">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { fade, fly, scale } from 'svelte/transition';
|
import { fade, fly, scale } from 'svelte/transition';
|
||||||
|
import { KeyboardKey } from '$lib/enums/keyboard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -117,7 +118,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleApiKeyKeydown(event: KeyboardEvent) {
|
function handleApiKeyKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === KeyboardKey.ENTER) {
|
||||||
handleSaveApiKey();
|
handleSaveApiKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
{model || 'Unknown Model'}
|
{model || 'Unknown Model'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{#if serverData.default_generation_settings.n_ctx}
|
{#if serverData?.default_generation_settings?.n_ctx}
|
||||||
<Badge variant="secondary" class="text-xs">
|
<Badge variant="secondary" class="text-xs">
|
||||||
ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()}
|
ctx: {serverData.default_generation_settings.n_ctx.toLocaleString()}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
{href}
|
{href}
|
||||||
class={cn(badgeVariants({ variant }), className)}
|
class={cn(badgeVariants({ variant }), className, 'backdrop-blur-sm')}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
'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:
|
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',
|
'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',
|
secondary:
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
'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'
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { cn, type WithElementRef } from '$lib/components/ui/utils';
|
import { cn, type WithElementRef } from '$lib/components/ui/utils';
|
||||||
|
import { BOX_BORDER } from '$lib/constants/css-classes';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|
@ -14,7 +15,8 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style="backdrop-filter: blur(0.5rem);"
|
||||||
{type}
|
{type}
|
||||||
bind:value
|
bind:value
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
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 PanelLeftIcon from '@lucide/svelte/icons/panel-left';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
import { useSidebar } from './context.svelte.js';
|
import { useSidebar } from './context.svelte.js';
|
||||||
|
|
@ -22,7 +21,7 @@
|
||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class={cn('size-7', className)}
|
class="rounded-full backdrop-blur-lg {className} h-9! w-9!"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
onclick?.(e);
|
onclick?.(e);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
bind:checked
|
bind:checked
|
||||||
data-slot="switch"
|
data-slot="switch"
|
||||||
class={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,28 @@
|
||||||
side = 'top',
|
side = 'top',
|
||||||
children,
|
children,
|
||||||
arrowClasses,
|
arrowClasses,
|
||||||
|
noPortal = false,
|
||||||
...restProps
|
...restProps
|
||||||
}: TooltipPrimitive.ContentProps & {
|
}: TooltipPrimitive.ContentProps & {
|
||||||
arrowClasses?: string;
|
arrowClasses?: string;
|
||||||
|
noPortal?: boolean;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
<TooltipPrimitive.Portal>
|
{#snippet tooltipContent()}
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
{side}
|
{side}
|
||||||
class={cn(
|
class={contentClass}
|
||||||
'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
|
|
||||||
)}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
@ -44,4 +50,12 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</TooltipPrimitive.Arrow>
|
</TooltipPrimitive.Arrow>
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if noPortal}
|
||||||
|
{@render tooltipContent()}
|
||||||
|
{:else}
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
{@render tooltipContent()}
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,7 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
|
||||||
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
||||||
const headers = { ...baseHeaders, ...customHeaders };
|
const headers = { ...baseHeaders, ...customHeaders };
|
||||||
|
|
||||||
const url =
|
const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
|
||||||
path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,6 @@ export { getLanguageFromFilename } from './syntax-highlight-language';
|
||||||
|
|
||||||
// Text file utilities
|
// Text file utilities
|
||||||
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
|
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
|
||||||
|
|
||||||
|
// Image error fallback utilities
|
||||||
|
export { getImageErrorFallbackHtml } from './image-error-fallback';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue