# PWA Artifacts
apple-splash-*.png
apple-touch-icon-*.png
-favicon.ico
-favicon-dark.ico
maskable-icon-*.png
pwa-*.png
+static/favicon*
# Storybook
*storybook.log
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
- "dompurify": "3.4.5",
+ "dompurify": "3.4.11",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
"peer": true
},
"node_modules/dompurify": {
- "version": "3.4.5",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
- "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
+ "version": "3.4.11",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+ "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
}
},
"node_modules/hono": {
- "version": "4.12.23",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
- "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
+ "version": "4.12.26",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
+ "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"dev": true,
"license": "MIT",
"engines": {
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
- "dompurify": "3.4.5",
+ "dompurify": "3.4.11",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
import { defineConfig } from '@vite-pwa/assets-generator/config';
+import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
+import { writeThemeFavicons } from './scripts/favicon-colorize';
+
+writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+});
export default defineConfig({
headLinkOptions: {
preset: {
transparent: {
sizes: [],
- favicons: [[48, 'favicon-dark.ico']]
+ favicons: [[48, 'favicon-dark.ico']],
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
},
maskable: {
sizes: []
} from '@vite-pwa/assets-generator/config';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
-import { THEME_COLORS, PWA_GENERATOR_DEVICES, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa';
+import {
+ THEME_COLORS,
+ PWA_GENERATOR_DEVICES,
+ PWA_ASSET_GENERATOR,
+ FAVICON_COLORS
+} from './src/lib/constants/pwa';
import { SplashOrientation } from './src/lib/enums/splash.enums';
+import { writeThemeFavicons } from './scripts/favicon-colorize';
+
+writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, {
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+});
export default defineConfig({
headLinkOptions: {
preset: PWA_ASSET_GENERATOR.LINK_PRESET
},
preset: combinePresetAndAppleSplashScreens(
- minimal2023Preset,
+ {
+ ...minimal2023Preset,
+ // tiny margin so favicon.ico / pwa-*.png breathe inside the canvas
+ transparent: {
+ ...minimal2023Preset.transparent,
+ padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
+ }
+ },
{
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
resizeOptions: {
--- /dev/null
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = resolve(HERE, '..');
+
+const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg');
+const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static');
+const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg');
+const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg');
+
+const CURRENT_COLOR = 'currentColor';
+
+export interface ColorizedFavicon {
+ light: string;
+ dark: string;
+}
+
+export interface WriteThemeFaviconsOptions {
+ sourcePath?: string;
+ lightOutPath?: string;
+ darkOutPath?: string;
+ /**
+ * Fraction of the icon (0..1) to leave as an even margin on each side.
+ * Applied by wrapping the inner content in a `<g transform="...">` so the
+ * source `src/lib/assets/logo.svg` is not modified. Pass 0 to disable.
+ */
+ padding?: number;
+}
+
+/**
+ * Replace every `currentColor` occurrence in the SVG with the given color.
+ * Pure: no filesystem access, so it is straightforward to unit-test.
+ */
+export function colorizeFaviconSvg(
+ svg: string,
+ lightColor: string,
+ darkColor: string
+): ColorizedFavicon {
+ return {
+ light: svg.replaceAll(CURRENT_COLOR, lightColor),
+ dark: svg.replaceAll(CURRENT_COLOR, darkColor)
+ };
+}
+
+/**
+ * Shrink the inner SVG content uniformly and re-center it so `padding` (a
+ * 0..1 fraction) is reserved as equal margin on each side. Returns the input
+ * unchanged for non-positive padding, missing/invalid `viewBox`, or unexpected
+ * markup so the caller always gets a renderable SVG.
+ */
+export function padFaviconSvg(svg: string, padding: number): string {
+ if (!(padding > 0) || padding >= 1) return svg;
+
+ const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i);
+ if (!viewBoxMatch) return svg;
+
+ const parts = viewBoxMatch[1]
+ .trim()
+ .split(/[\s,]+/)
+ .map(Number);
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg;
+
+ const [, , width, height] = parts;
+ if (width <= 0 || height <= 0) return svg;
+
+ const scale = 1 - padding;
+ const translateX = (padding * width) / 2;
+ const translateY = (padding * height) / 2;
+
+ const openTagStart = svg.search(/<svg\b/i);
+ if (openTagStart === -1) return svg;
+ const openTagEnd = svg.indexOf('>', openTagStart);
+ if (openTagEnd === -1) return svg;
+ const closeStart = svg.lastIndexOf('</svg');
+ if (closeStart === -1 || closeStart <= openTagEnd) return svg;
+
+ const openTag = svg.slice(0, openTagEnd + 1);
+ const inner = svg.slice(openTagEnd + 1, closeStart);
+ const closeTag = svg.slice(closeStart);
+
+ const group = `<g transform="translate(${translateX} ${translateY}) scale(${scale})">`;
+ return `${openTag}${group}${inner}</g>${closeTag}`;
+}
+
+/**
+ * Read `src/lib/assets/logo.svg`, colorize it for both themes, and write
+ * the results to the static directory so the PWA asset generator can consume
+ * them. Paths can be overridden for tests.
+ */
+export function writeThemeFavicons(
+ lightColor: string,
+ darkColor: string,
+ {
+ sourcePath = DEFAULT_LOGO,
+ lightOutPath = DEFAULT_OUT_LIGHT,
+ darkOutPath = DEFAULT_OUT_DARK,
+ padding = 0
+ }: WriteThemeFaviconsOptions = {}
+): void {
+ const source = readFileSync(sourcePath, 'utf-8');
+ const { light, dark } = colorizeFaviconSvg(source, lightColor, darkColor);
+ mkdirSync(dirname(lightOutPath), { recursive: true });
+ writeFileSync(lightOutPath, padFaviconSvg(light, padding));
+ writeFileSync(darkOutPath, padFaviconSvg(dark, padding));
+}
--chat-form-area-height: 8rem;
--chat-form-area-offset: 2rem;
+ --chat-form-padding-top: 6rem;
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
}
:root {
--chat-form-area-height: 24rem;
--chat-form-area-offset: 12rem;
+ --chat-form-padding-top: 6rem;
}
}
@apply bg-background text-foreground;
scrollbar-width: thin;
scrollbar-gutter: stable;
- overflow: hidden; /* Added due to Mermaid rendering somehow causing the double scrollbar */
}
/* Global scrollbar styling - visible only on hover */
scrollbar-width: none;
}
}
+
+.mermaidTooltip {
+ display: none !important;
+}
*/
export function fadeInView(
node: HTMLElement,
- options: { duration?: number; y?: number; skipIfVisible?: boolean } = {}
+ options: { duration?: number; y?: number; delay?: number; skipIfVisible?: boolean } = {}
) {
- const { duration = 300, y = 0, skipIfVisible = false } = options;
+ const { duration = 300, y = 0, delay = 0, skipIfVisible = false } = options;
if (skipIfVisible && isElementInViewport(node)) {
return;
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
- requestAnimationFrame(() => {
- node.style.opacity = '1';
- node.style.transform = 'translateY(0)';
- });
+ setTimeout(() => {
+ requestAnimationFrame(() => {
+ node.style.opacity = '1';
+ node.style.transform = 'translateY(0)';
+ });
+ }, delay);
observer.disconnect();
}
}
--- /dev/null
+<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M244.95 8C215.233 8 187.774 23.8591 172.923 49.5999L95.6009 183.625C60.2162 244.959 104.481 321.6 175.29 321.6H208L316.977 132.708C348.959 77.2719 308.95 8 244.95 8ZM208 321.6H351.947C415.982 321.6 456.013 390.91 424.013 446.377C409.155 472.132 381.681 488 351.947 488H271.29C200.481 488 156.216 411.359 191.601 350.026L208 321.6Z" fill="currentColor"/>
+ <path d="M208 321.6H16L106.462 164.8L208 321.6Z" fill="currentColor"/>
+ <path d="M388.923 8L208 321.6L253.6 8H388.923Z" fill="currentColor"/>
+ <path d="M304 488H112L202.462 331.2L304 488Z" fill="currentColor"/>
+ <path d="M496 321.6H208L419.399 454.4L496 321.6Z" fill="currentColor"/>
+</svg>
ariaLabel?: string;
class?: string;
disabled?: boolean;
+ href?: string;
icon: Component;
iconSize?: string;
- onclick: (e?: MouseEvent) => void;
+ onclick?: (e?: MouseEvent) => void;
size?: ButtonSize;
stopPropagationOnClick?: boolean;
- tooltip: string;
+ tooltip?: string;
variant?: ButtonVariant;
tooltipSide?: TooltipSide;
}
icon,
tooltip,
variant = 'ghost',
+ href = '',
size = 'sm',
class: className = '',
disabled = false,
onclick,
ariaLabel
}: Props = $props();
+
+ let innerWidth = $state(0);
+ const showTooltip = $derived(!!tooltip && innerWidth > 768);
</script>
-<Tooltip.Root>
- <Tooltip.Trigger>
- <!-- prevent another nested button element -->
- {#snippet child({ props })}
- <Button
- {...props}
- {variant}
- {size}
- {disabled}
- onclick={(e: MouseEvent) => {
- if (stopPropagationOnClick) e.stopPropagation();
+{#snippet button(props = {})}
+ <Button
+ {...props}
+ {href}
+ {variant}
+ {size}
+ {disabled}
+ onclick={(e: MouseEvent) => {
+ if (stopPropagationOnClick) e.stopPropagation();
+
+ onclick?.(e);
+ }}
+ class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
+ aria-label={ariaLabel || tooltip}
+ >
+ {#if icon}
+ {@const IconComponent = icon}
+
+ <IconComponent class={iconSize} />
+ {/if}
+ </Button>
+{/snippet}
+
+{#if showTooltip}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <!-- prevent another nested button element -->
+ {#snippet child({ props })}
+ {@render button(props)}
+ {/snippet}
+ </Tooltip.Trigger>
- onclick?.(e);
- }}
- class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
- aria-label={ariaLabel || tooltip}
- >
- {#if icon}
- {@const IconComponent = icon}
- <IconComponent class={iconSize} />
- {/if}
- </Button>
- {/snippet}
- </Tooltip.Trigger>
+ <Tooltip.Content side={tooltipSide}>
+ <p>{tooltip}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+{:else}
+ {@render button({ href })}
+{/if}
- <Tooltip.Content side={tooltipSide}>
- <p>{tooltip}</p>
- </Tooltip.Content>
-</Tooltip.Root>
+<svelte:window bind:innerWidth />
/>
<div
- class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
+ class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
/>
<div
- class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3"
+ class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
onpaste={handlePaste}
>
<ChatFormTextarea
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
- class="file-upload-button h-8 w-8 rounded-full p-0"
+ class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
{disabled}
{onclick}
variant="secondary"
import { McpLogo } from '$lib/components/app';
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
import { HealthCheckStatus } from '$lib/enums';
+ import { AttachmentAction } from '$lib/enums/attachment.enums';
interface Props {
class?: string;
</Collapsible.Root>
{/if}
- <button type="button" class={sheetItemClass} onclick={onSystemPromptClick}>
+ <button
+ type="button"
+ class={sheetItemClass}
+ onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
+ >
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
{#if hasMcpPromptsSupport}
- <button type="button" class={sheetItemClass} onclick={onMcpPromptClick}>
+ <button
+ type="button"
+ class={sheetItemClass}
+ onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
+ >
<Zap class="h-4 w-4 shrink-0" />
<span>MCP Prompt</span>
{/if}
{#if hasMcpResourcesSupport}
- <button type="button" class={sheetItemClass} onclick={onMcpResourcesClick}>
+ <button
+ type="button"
+ class={sheetItemClass}
+ onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
+ >
<FolderOpen class="h-4 w-4 shrink-0" />
<span>MCP Resources</span>
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
+ {onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
>
type="submit"
disabled={isDisabled}
class={[
- 'h-8 w-8 rounded-full p-0',
+ 'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
showErrorState &&
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
]}
<script lang="ts">
+ import { isMobile } from '$lib/stores/viewport.svelte';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
}
export function focus() {
- textareaElement?.focus();
+ if (isMobile.current) return;
+
+ textareaElement?.focus({ preventScroll: true });
}
export function resetHeight() {
editedContent = message.content;
}
- textareaElement?.focus();
+ textareaElement?.focus({ preventScroll: true });
editedExtras = message.extra ? [...message.extra] : [];
editedUploadedFiles = [];
}
</script>
-<div use:fadeInView>
+<div use:fadeInView class="chat-message">
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
isLastAssistantMessage
);
+ let assistantEl: HTMLDivElement | undefined = $state();
+ let lastUserMessageHeight = $state(0);
+ let assistantMarginTop = $state(0);
+
+ $effect(() => {
+ if (!assistantEl) return;
+
+ assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
+
+ const chatMessageEl = assistantEl.closest('.chat-message');
+ const previousChatMessage = chatMessageEl?.previousElementSibling;
+ const userMessageEl = previousChatMessage?.querySelector(
+ '.chat-message-user'
+ ) as HTMLElement | null;
+
+ if (!userMessageEl) {
+ lastUserMessageHeight = 0;
+ return;
+ }
+
+ const updateHeight = () => {
+ const rect = userMessageEl.getBoundingClientRect();
+ const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
+ lastUserMessageHeight = Math.round(rect.height + marginTop);
+ };
+
+ updateHeight();
+
+ const resizeObserver = new ResizeObserver(updateHeight);
+ resizeObserver.observe(userMessageEl);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ });
+
function handleCopyModel() {
void copyToClipboard(displayedModel ?? '');
}
</script>
<div
- class="text-md group w-full leading-7.5 {className}"
+ bind:this={assistantEl}
+ class="chat-message-assistant text-md group w-full leading-7.5 {className}"
+ style:--last-user-message-height={lastUserMessageHeight > 0
+ ? `${lastUserMessageHeight}px`
+ : undefined}
+ style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
role="group"
aria-label="Assistant message with actions"
>
{#if showProcessingInfoTop}
- <div class="mt-6 w-full max-w-[48rem]" in:fade>
+ <div class="mt-6 w-full max-w-3xl" in:fade>
<div class="processing-container">
<span class="processing-text">
{modelLoadingText ??
{/if}
{#if showProcessingInfoBottom}
- <div class="mt-4 w-full max-w-[48rem]" in:fade>
+ <div class="mt-4 w-full max-w-3xl" in:fade>
<div class="processing-container">
<span class="processing-text">
{modelLoadingText ??
</div>
<style>
+ :global(.chat-message):last-child .chat-message-assistant {
+ --assistant-min-height-offset: calc(
+ var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
+ var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
+ var(--assistant-margin-top, 3rem)
+ );
+ min-height: calc(100dvh - var(--assistant-min-height-offset));
+
+ @media (width > 768px) {
+ --assistant-min-height-offset: calc(
+ var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
+ var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
+ var(--assistant-margin-top, 3rem)
+ );
+ }
+ }
+
.processing-container {
display: flex;
flex-direction: column;
<div
aria-label="User message with actions"
- class="group flex flex-col items-end gap-3 md:gap-2 {className}"
+ class="chat-message-user group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
{#if editCtx.isEditing}
renderMarkdown = false,
textColorClass = 'text-foreground',
cardBgClass = 'dark:bg-primary/15',
- maxHeightStyle = 'max-height: var(--max-message-height);'
+ maxHeightStyle = ''
}: Props = $props();
let isMultiline = $state(false);
{#if content.trim()}
<Card
- class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 {textColorClass} backdrop-blur-md data-[multiline]:py-2.5 {cardBgClass}"
+ class="chat-message-user-bubble max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 {textColorClass} backdrop-blur-md data-multiline:py-2.5 {cardBgClass}"
data-multiline={isMultiline ? '' : undefined}
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
let allConversationMessages = $state<DatabaseMessage[]>([]);
let isVisible = $state(false);
let previousConversationId = $state<string | null>(null);
+ let previousRouteId = $state<string | null>(null);
const currentConfig = config();
});
});
- beforeNavigate(() => {
+ beforeNavigate((navigation) => {
isVisible = false;
+ previousRouteId = navigation.from?.route.id ?? null;
});
afterNavigate(() => {
</script>
<div
- class="transition-opacity delay-300 duration-500 ease-out
- {isVisible ? 'opacity-100' : 'opacity-0'}"
+ class="transition-opacity duration-500 ease-out
+ {isVisible ? 'opacity-100' : 'opacity-0'}
+ {previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
>
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
<ChatMessage
- class="mx-auto mt-12 w-full max-w-[48rem]"
+ class="mx-auto mt-12 w-full max-w-3xl"
{message}
{toolMessages}
{isLastAssistantMessage}
<script lang="ts">
- import { Trash2 } from '@lucide/svelte';
- import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import {
ChatScreenForm,
ChatMessages,
ChatScreenDragOverlay,
ChatScreenProcessingInfo,
- ChatScreenActionScrollDown,
- DialogEmptyFileAlert,
- DialogFileUploadError,
- DialogChatError,
ServerLoadingSplash,
- DialogConfirmation,
ChatScreenServerError
} from '$lib/components/app';
import { setProcessingInfoContext } from '$lib/contexts';
- import { ErrorDialogType } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+ import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
+ import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
+ import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
+ import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
+ import { device } from '$lib/stores/device.svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
import {
chatStore,
errorDialog,
isLoading,
isChatStreaming,
isEditing,
- getAddFilesHandler,
activeProcessingState
} from '$lib/stores/chat.svelte';
import {
activeConversation
} from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
- import { serverLoading, serverError, isRouterMode } from '$lib/stores/server.svelte';
- import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
- import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
- import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
- import { onMount } from 'svelte';
+ import { serverLoading, serverError } from '$lib/stores/server.svelte';
+ import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
+ import { onDestroy, onMount } from 'svelte';
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
+ import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
+ import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
+ import { ROUTES } from '$lib/constants';
let { showCenteredEmpty = false } = $props();
- const autoScroll = createAutoScrollController();
-
- let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
- let chatScrollContainer: HTMLDivElement | undefined = $state();
- let dragCounter = $state(0);
- let isDragOver = $state(false);
- let showFileErrorDialog = $state(false);
- let uploadedFiles = $state<ChatUploadedFile[]>([]);
-
- let fileErrorData = $state<{
- generallyUnsupported: File[];
- modalityUnsupported: File[];
- modalityReasons: Record<string, string>;
- supportedTypes: string[];
- }>({
- generallyUnsupported: [],
- modalityUnsupported: [],
- modalityReasons: {},
- supportedTypes: []
+ setProcessingInfoContext({
+ get showProcessingInfo() {
+ return showProcessingInfo;
+ }
});
- let showDeleteDialog = $state(false);
-
- let showEmptyFileDialog = $state(false);
-
- let processingInfoVisible = $state(false);
-
+ let disableAutoScroll = $derived(Boolean(config().disableAutoScroll) || isMobile.current);
+ let isMobileUserScrolledUp = $state(false);
+ let mobileScrollDownHint = $state(false);
+ let mobileScrollDownHintLockedUntil = $state(0);
let emptyFileNames = $state<string[]>([]);
-
let initialMessage = $state('');
-
+ let showDeleteDialog = $state(false);
+ let showEmptyFileDialog = $state(false);
let isEmpty = $derived(
showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
);
-
let activeErrorDialog = $derived(errorDialog());
let isServerLoading = $derived(serverLoading());
let hasPropsError = $derived(!!serverError());
-
let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
-
let showProcessingInfo = $derived(
isCurrentConversationLoading ||
(config().keepStatsVisible && !!page.params.id) ||
activeProcessingState() !== null
);
-
- let isRouter = $derived(isRouterMode());
-
- let conversationModel = $derived(
- chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
- );
-
- let activeModelId = $derived.by(() => {
- const options = modelOptions();
-
- if (!isRouter) {
- return options.length > 0 ? options[0].model : null;
- }
-
- const selectedId = selectedModelId();
- if (selectedId) {
- const model = options.find((m) => m.id === selectedId);
- if (model) return model.model;
- }
-
- if (conversationModel) {
- const model = options.find((m) => m.model === conversationModel);
- if (model) return model.model;
- }
-
- return null;
+ let chatFormBottomPosition = $derived.by(() => {
+ if (!isMobile.current) return '1rem';
+ if (device.isStandalone) return '1.5rem';
+ if (device.isIOSSafari) return '0.25rem';
+ return '0.5rem';
});
- let modelPropsVersion = $state(0);
-
- setProcessingInfoContext({
- get showProcessingInfo() {
- return showProcessingInfo;
- }
- });
-
- $effect(() => {
- if (activeModelId) {
- const cached = modelsStore.getModelProps(activeModelId);
-
- if (!cached) {
- modelsStore.fetchModelProps(activeModelId).then(() => {
- modelPropsVersion++;
- });
- }
- }
+ const autoScroll = createAutoScrollController();
+ const scroll = useChatScreenScroll(autoScroll);
+ const activeModel = useChatScreenActiveModel();
+ const fileUpload = useChatScreenFileUpload({
+ capabilities: () => ({
+ hasVision: activeModel.hasVisionModality,
+ hasAudio: activeModel.hasAudioModality,
+ hasVideo: activeModel.hasVideoModality
+ }),
+ activeModelId: () => activeModel.activeModelId
});
-
- let hasAudioModality = $derived.by(() => {
- if (activeModelId) {
- void modelPropsVersion;
-
- return modelsStore.modelSupportsAudio(activeModelId);
- }
-
- return false;
+ const dragAndDrop = useChatScreenDragAndDrop({
+ onDrop: fileUpload.handleFileUpload
});
-
- let hasVideoModality = $derived.by(() => {
- if (activeModelId) {
- void modelPropsVersion;
-
- return modelsStore.modelSupportsVideo(activeModelId);
+ const { handleKeydown } = useKeyboardShortcuts({
+ deleteActiveConversation: () => {
+ if (activeConversation()) {
+ showDeleteDialog = true;
+ }
}
-
- return false;
});
- let hasVisionModality = $derived.by(() => {
- if (activeModelId) {
- void modelPropsVersion;
+ function handleMobileScroll() {
+ if (!isMobile.current) return;
- return modelsStore.modelSupportsVision(activeModelId);
- }
+ const container = scroll.chatScrollContainer;
+ if (!container) return;
- return false;
- });
+ const distanceFromBottom =
+ container.scrollHeight - container.clientHeight - container.scrollTop;
+ isMobileUserScrolledUp = distanceFromBottom > 300;
+ }
async function handleDeleteConfirm() {
const conversation = activeConversation();
showDeleteDialog = false;
}
- function handleProcessingInfoVisibility(visible: boolean) {
- processingInfoVisible = visible;
- }
-
- function handleDragEnter(event: DragEvent) {
- event.preventDefault();
-
- dragCounter++;
-
- if (event.dataTransfer?.types.includes('Files')) {
- isDragOver = true;
- }
- }
-
- function handleDragLeave(event: DragEvent) {
- event.preventDefault();
-
- dragCounter--;
-
- if (dragCounter === 0) {
- isDragOver = false;
- }
- }
-
- function handleErrorDialogOpenChange(open: boolean) {
- if (!open) {
- chatStore.dismissErrorDialog();
- }
- }
-
- function handleDragOver(event: DragEvent) {
- event.preventDefault();
- }
-
- function handleDrop(event: DragEvent) {
- event.preventDefault();
-
- isDragOver = false;
- dragCounter = 0;
-
- if (event.dataTransfer?.files) {
- const files = Array.from(event.dataTransfer.files);
-
- if (isEditing()) {
- const handler = getAddFilesHandler();
-
- if (handler) {
- handler(files);
- return;
- }
- }
-
- processFiles(files);
- }
- }
-
- function handleFileRemove(fileId: string) {
- uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
- }
-
- function handleFileUpload(files: File[]) {
- processFiles(files);
- }
-
- const { handleKeydown } = useKeyboardShortcuts({
- deleteActiveConversation: () => {
- if (activeConversation()) {
- showDeleteDialog = true;
- }
- }
- });
-
- async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
- if (draft.message || draft.files.length > 0) {
- chatStore.savePendingDraft(draft.message, draft.files);
- }
-
- await chatStore.addSystemPrompt();
- }
-
- function handleScroll() {
- autoScroll.handleScroll();
- }
-
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const plainFiles = files ? $state.snapshot(files) : undefined;
const result = plainFiles
- ? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
+ ? await parseFilesToMessageExtras(plainFiles, activeModel.activeModelId ?? undefined)
: undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) {
emptyFileNames = result.emptyFiles;
showEmptyFileDialog = true;
-
if (files) {
const emptyFileNamesSet = new Set(result.emptyFiles);
- uploadedFiles = uploadedFiles.filter((file) => !emptyFileNamesSet.has(file.name));
+ fileUpload.uploadedFiles = fileUpload.uploadedFiles.filter(
+ (file) => !emptyFileNamesSet.has(file.name)
+ );
}
return false;
}
- const extras = result?.extras;
-
- // Enable autoscroll for user-initiated message sending
- autoScroll.enable();
- await chatStore.sendMessage(message, extras);
- autoScroll.scrollToBottom();
+ handleSendLikeScroll();
+ await chatStore.sendMessage(message, result?.extras);
return true;
}
- async function processFiles(files: File[]) {
- const generallySupported: File[] = [];
- const generallyUnsupported: File[] = [];
+ function handleSendLikeScroll() {
+ if (!isMobile.current) {
+ autoScroll.enable();
+ }
+
+ setTimeout(() => {
+ const container = scroll.chatScrollContainer;
+ if (!container) return;
- for (const file of files) {
- if (isFileTypeSupported(file.name, file.type)) {
- generallySupported.push(file);
+ const lastUserBubble = container.querySelector(
+ '.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
+ ) as HTMLElement | null;
+
+ if (isMobile.current) {
+ // Keep the last user message bubble just above the input on mobile
+ const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
+ const baseHeight = container.scrollHeight - innerHeight;
+
+ container.scrollTo({
+ top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight,
+ behavior: 'smooth'
+ });
+ } else if (lastUserBubble) {
+ // On desktop, place the last user message near the top of the viewport
+ const topPadding = 24;
+ const bubbleRect = lastUserBubble.getBoundingClientRect();
+ container.scrollTo({
+ top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding),
+ behavior: 'smooth'
+ });
} else {
- generallyUnsupported.push(file);
+ autoScroll.scrollToBottom();
}
+ }, 100);
+
+ if (isMobile.current) {
+ autoScroll.setDisabled(disableAutoScroll);
+ mobileScrollDownHint = true;
+ mobileScrollDownHintLockedUntil = Date.now() + 500;
}
+ }
- // Use model-specific capabilities for file validation
- const capabilities = {
- hasVision: hasVisionModality,
- hasAudio: hasAudioModality,
- hasVideo: hasVideoModality
- };
- const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
- generallySupported,
- capabilities
- );
-
- const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
-
- if (allUnsupportedFiles.length > 0) {
- const supportedTypes: string[] = ['text files', 'PDFs'];
-
- if (hasVisionModality) supportedTypes.push('images');
- if (hasAudioModality) supportedTypes.push('audio files');
- if (hasVideoModality) supportedTypes.push('video files');
-
- fileErrorData = {
- generallyUnsupported,
- modalityUnsupported: unsupportedFiles,
- modalityReasons,
- supportedTypes
- };
- showFileErrorDialog = true;
+ function handleErrorDialogOpenChange(open: boolean) {
+ if (!open) {
+ chatStore.dismissErrorDialog();
}
+ }
- if (supportedFiles.length > 0) {
- const processed = await processFilesToChatUploaded(
- supportedFiles,
- activeModelId ?? undefined
- );
- uploadedFiles = [...uploadedFiles, ...processed];
+ async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
+ if (draft.message || draft.files.length > 0) {
+ chatStore.savePendingDraft(draft.message, draft.files);
}
+ await chatStore.addSystemPrompt();
}
- afterNavigate(() => {
- if (!disableAutoScroll) {
+ $effect(() => {
+ const shouldDisableAutoScroll =
+ config().disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
+ autoScroll.setDisabled(shouldDisableAutoScroll);
+ if (!shouldDisableAutoScroll) {
autoScroll.enable();
}
});
- function handleMessagesReady() {
- if (disableAutoScroll) return;
-
- if (!autoScroll.userScrolledUp) {
- requestAnimationFrame(() => {
- autoScroll.scrollToBottom('instant');
- });
+ onMount(() => {
+ const pendingDraft = chatStore.consumePendingDraft();
+ if (pendingDraft) {
+ initialMessage = pendingDraft.message;
+ fileUpload.uploadedFiles = pendingDraft.files;
}
- }
- onMount(() => {
autoScroll.startObserving();
if (!disableAutoScroll) {
autoScroll.enable();
}
- const pendingDraft = chatStore.consumePendingDraft();
- if (pendingDraft) {
- initialMessage = pendingDraft.message;
- uploadedFiles = pendingDraft.files;
+ if (isMobile.current && isCurrentConversationLoading) {
+ mobileScrollDownHint = true;
+ mobileScrollDownHintLockedUntil = Date.now() + 500;
}
- });
- $effect(() => {
- autoScroll.setContainer(chatScrollContainer);
+ handleMobileScroll();
});
- $effect(() => {
- autoScroll.setDisabled(disableAutoScroll);
- });
+ onDestroy(() => autoScroll.destroy());
</script>
-{#if isDragOver}
+{#if dragAndDrop.isDragOver}
<ChatScreenDragOverlay />
{/if}
-<svelte:window onkeydown={handleKeydown} />
+<svelte:window
+ onkeydown={handleKeydown}
+ onscroll={(e) => {
+ scroll.handleScroll(e);
+ handleMobileScroll();
+ if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
+ mobileScrollDownHint = false;
+ }
+ }}
+/>
{#if isServerLoading}
<ServerLoadingSplash />
{:else}
<div
- bind:this={chatScrollContainer}
- aria-label="Chat interface with file drop zone"
- class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
- ondragenter={handleDragEnter}
- ondragleave={handleDragLeave}
- ondragover={handleDragOver}
- ondrop={handleDrop}
- onscroll={handleScroll}
+ class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-full px-4 md:py-0 pt-12 pb-48 md:pb-4"
+ style:--chat-form-bottom-position={chatFormBottomPosition}
+ ondragenter={dragAndDrop.dragHandlers.dragenter}
+ ondragleave={dragAndDrop.dragHandlers.dragleave}
+ ondragover={dragAndDrop.dragHandlers.dragover}
+ ondrop={dragAndDrop.dragHandlers.drop}
role="main"
>
- <div class="flex grow flex-col pt-14">
- {#if !isEmpty}
- <ChatMessages
- messages={activeMessages()}
- onMessagesReady={handleMessagesReady}
- onUserAction={() => {
- autoScroll.enable();
- if (!autoScroll.userScrolledUp) {
- autoScroll.scrollToBottom();
- }
- }}
- />
- {/if}
-
- <div
- class={[
- 'pointer-events-none sticky right-4 left-4 mt-auto transition-all duration-200',
- isEmpty ? 'bottom-[calc(50dvh-7rem)]' : 'bottom-4 pt-24 md:pt-32'
- ]}
- >
- <ChatScreenGreeting {isEmpty} />
-
- <ChatScreenActionScrollDown
- container={chatScrollContainer}
- hasProcessingInfoVisible={processingInfoVisible}
- />
-
- <ChatScreenProcessingInfo onVisibilityChange={handleProcessingInfoVisibility} />
-
- <ChatScreenServerError />
-
- <div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
- <ChatScreenForm
- disabled={hasPropsError || isEditing()}
- {initialMessage}
- isLoading={isCurrentConversationLoading}
- onFileRemove={handleFileRemove}
- onFileUpload={handleFileUpload}
- onSend={handleSendMessage}
- onStop={() => chatStore.stopGeneration()}
- onSystemPromptAdd={handleSystemPromptAdd}
- bind:uploadedFiles
+ {#if !isEmpty}
+ <ChatMessages
+ messages={activeMessages()}
+ onUserAction={() => {
+ handleSendLikeScroll();
+ }}
+ />
+ {/if}
+
+ <div
+ class={[
+ 'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
+ device.isStandalone
+ ? 'bottom-6 right-4 left-4'
+ : device.isIOSSafari
+ ? 'bottom-1 left-2 right-2'
+ : 'bottom-2 right-2 left-2',
+ isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
+ ]}
+ style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
+ >
+ <ChatScreenGreeting {isEmpty} />
+
+ <ChatScreenServerError />
+
+ <div class="pointer-events-none flex flex-col gap-6 items-center w-full">
+ {#if (isMobile.current ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
+ <ChatScreenActionScrollDown
+ onclick={() => {
+ mobileScrollDownHint = false;
+ scroll.chatScrollContainer?.scrollTo({
+ top: scroll.chatScrollContainer.scrollHeight,
+ behavior: 'smooth'
+ });
+ }}
/>
- </div>
+ {/if}
+
+ {#if showProcessingInfo}
+ <ChatScreenProcessingInfo />
+ {/if}
</div>
+
+ <ChatScreenForm
+ class="pointer-events-auto conversation-chat-form"
+ disabled={hasPropsError || isEditing()}
+ {initialMessage}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={fileUpload.handleFileRemove}
+ onFileUpload={fileUpload.handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ onSystemPromptAdd={handleSystemPromptAdd}
+ bind:uploadedFiles={fileUpload.uploadedFiles}
+ />
</div>
</div>
{/if}
-<DialogFileUploadError bind:open={showFileErrorDialog} {fileErrorData} />
-
-<DialogConfirmation
- bind:open={showDeleteDialog}
- title="Delete Conversation"
- description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
- confirmText="Delete"
- cancelText="Cancel"
- variant="destructive"
- icon={Trash2}
- onConfirm={handleDeleteConfirm}
- onCancel={() => (showDeleteDialog = false)}
-/>
-
-<DialogEmptyFileAlert
- bind:open={showEmptyFileDialog}
- emptyFiles={emptyFileNames}
- onOpenChange={(open) => {
- if (!open) {
- emptyFileNames = [];
- }
- }}
-/>
-
-<DialogChatError
- message={activeErrorDialog?.message ?? ''}
- contextInfo={activeErrorDialog?.contextInfo}
- onOpenChange={handleErrorDialogOpenChange}
- open={Boolean(activeErrorDialog)}
- type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
+<ChatScreenDialogsAndAlerts
+ {showDeleteDialog}
+ {handleDeleteConfirm}
+ {showEmptyFileDialog}
+ {emptyFileNames}
+ {activeErrorDialog}
+ {handleErrorDialogOpenChange}
+ {fileUpload}
/>
<script lang="ts">
import { ArrowDown } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
+ import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
- interface Props {
- container: HTMLDivElement | undefined;
- hasProcessingInfoVisible: boolean;
- }
-
- let { container, hasProcessingInfoVisible }: Props = $props();
-
- let show = $state(false);
-
- let buttonBottom = $derived(hasProcessingInfoVisible ? '2rem' : '0');
-
- function checkVisibility() {
- if (!container) return;
- const { scrollTop, scrollHeight, clientHeight } = container;
- const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
- show = distanceFromBottom > clientHeight * 0.5;
- }
-
- function scrollToBottom() {
- if (container) {
- container.scrollTo({
- top: container.scrollHeight,
- behavior: 'smooth'
- });
- }
- }
-
- $effect(() => {
- const c = container;
- if (c) {
- c.addEventListener('scroll', checkVisibility);
- checkVisibility();
- return () => {
- c.removeEventListener('scroll', checkVisibility);
- };
- }
- });
+ let { onclick }: { onclick: (e?: MouseEvent) => void } = $props();
</script>
-<div class="relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center">
- <Button
- onclick={scrollToBottom}
- variant="secondary"
- size="icon"
- disabled={!show}
- class="pointer-events-auto absolute h-10 w-10 rounded-full bg-background/80 shadow-lg backdrop-blur-sm transition-all duration-200 hover:bg-muted/80"
- style="bottom: {buttonBottom}; transform: translateY({show ? '0' : '2rem'}); opacity: {show
- ? 1
- : 0};"
- aria-label="Scroll to bottom"
- >
- <ArrowDown class="h-4 w-4" />
- </Button>
+<div class="pointer-events-auto flex justify-center relative h-0">
+ <ActionIcon
+ icon={ArrowDown}
+ {onclick}
+ ariaLabel="Scroll to bottom"
+ tooltip="Scroll to bottom"
+ size="lg"
+ iconSize="h-4 w-4"
+ class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
+ />
</div>
--- /dev/null
+<script lang="ts">
+ import { Trash2 } from '@lucide/svelte';
+ import { ErrorDialogType } from '$lib/enums';
+ import {
+ DialogChatError,
+ DialogConfirmation,
+ DialogEmptyFileAlert,
+ DialogFileUploadError
+ } from '$lib/components/app';
+
+ let {
+ showDeleteDialog,
+ handleDeleteConfirm,
+ showEmptyFileDialog,
+ emptyFileNames,
+ activeErrorDialog,
+ handleErrorDialogOpenChange,
+ fileUpload
+ } = $props();
+</script>
+
+<DialogFileUploadError
+ bind:open={fileUpload.showFileErrorDialog}
+ fileErrorData={fileUpload.fileErrorData}
+/>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Conversation"
+ description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
+ confirmText="Delete"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleDeleteConfirm}
+ onCancel={() => (showDeleteDialog = false)}
+/>
+
+<DialogEmptyFileAlert
+ bind:open={showEmptyFileDialog}
+ emptyFiles={emptyFileNames}
+ onOpenChange={(open) => {
+ if (!open) {
+ emptyFileNames = [];
+ }
+ }}
+/>
+
+<DialogChatError
+ message={activeErrorDialog?.message ?? ''}
+ contextInfo={activeErrorDialog?.contextInfo}
+ onOpenChange={handleErrorDialogOpenChange}
+ open={Boolean(activeErrorDialog)}
+ type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
+/>
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
+ import { isMobile } from '$lib/stores/viewport.svelte';
import { onMount } from 'svelte';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
}: Props = $props();
let chatFormRef: ChatForm | undefined = $state(undefined);
+ let formWrapperEl: HTMLDivElement | undefined = $state();
let chatId = $derived(page.params.id as string | undefined);
+
+ $effect(() => {
+ if (!formWrapperEl) return;
+
+ const formEl = formWrapperEl.querySelector('form') as HTMLElement | null;
+ if (!formEl) return;
+
+ const updateHeight = () => {
+ const height = Math.round(formEl.getBoundingClientRect().height);
+ document.documentElement.style.setProperty('--chat-form-height', `${height}px`);
+ };
+
+ updateHeight();
+
+ const resizeObserver = new ResizeObserver(updateHeight);
+ resizeObserver.observe(formEl);
+
+ return () => {
+ resizeObserver.disconnect();
+ document.documentElement.style.removeProperty('--chat-form-height');
+ };
+ });
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let message = $derived(initialMessage);
let previousIsLoading = $derived(isLoading);
}
onMount(() => {
- setTimeout(() => chatFormRef?.focus(), 10);
+ if (!isMobile.current) {
+ setTimeout(() => chatFormRef?.focus(), 100);
+ }
});
afterNavigate((navigation) => {
- if (navigation?.from != null) {
- setTimeout(() => chatFormRef?.focus(), 10);
+ if (navigation?.from != null && !isMobile.current) {
+ setTimeout(() => chatFormRef?.focus(), 100);
}
});
});
</script>
-<div class="relative mx-auto max-w-[48rem]">
+<div class="chat-screen-form-wrapper" bind:this={formWrapperEl}>
<ChatForm
+ class="mx-auto max-w-3xl {className}"
bind:this={chatFormRef}
bind:value={message}
bind:uploadedFiles
- class={className}
{disabled}
{isLoading}
showMcpPromptButton
<script lang="ts">
- import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { serverStore } from '$lib/stores/server.svelte';
interface Props {
<div
class={[
- 'pointer-events-none mb-4 hidden px-4 text-center',
- isEmpty && 'pointer-events-auto block!'
+ 'pointer-events-none mb-4 hidden px-4 text-center text-balance',
+ isEmpty && 'mb-[calc(50dvh-8rem)] md:mb-6 pointer-events-auto block!'
]}
- use:fadeInView={{ duration: 300 }}
>
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
- import { getProcessingInfoContext } from '$lib/contexts';
- import { page } from '$app/state';
const processingState = useProcessingState();
- const processingInfoCtx = getProcessingInfoContext();
-
- let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
let isCurrentConversationLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming());
<div
class={[
- 'chat-processing-info-container pointer-events-none relative',
- page.params.id && showProcessingInfo && 'visible'
+ 'chat-processing-info-container pointer-events-none relative w-full hidden md:block',
+ processingVisible && 'visible'
]}
>
<div class="chat-processing-info-content absolute bottom-4 left-1/2 -translate-x-1/2">
*/
export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte';
-/**
- * Scroll-to-bottom action button. Displays a floating button when the user
- * has scrolled up more than half a viewport height from the bottom.
- * Takes the chat container element as a prop to manage scroll state internally.
- */
-export { default as ChatScreenActionScrollDown } from './ChatScreen/ChatScreenActionScrollDown.svelte';
-
/**
* Server error alert displayed when the server is unreachable.
* Shows the error message with a retry button.
import { Search, X } from '@lucide/svelte';
interface Props {
+ autofocus?: boolean;
value?: string;
placeholder?: string;
onInput?: (value: string) => void;
}
let {
+ autofocus,
value = $bindable(''),
placeholder = 'Search...',
onInput,
if (value) {
value = '';
onInput?.('');
- ref?.focus();
+ ref?.focus({ preventScroll: true });
} else {
onClose?.();
}
/>
<Input
+ {autofocus}
{id}
bind:value
bind:ref
--- /dev/null
+<script>
+ import logoMark from '$lib/assets/logo.svg?raw';
+ let { class: className = '', style = '' } = $props();
+</script>
+
+<div class={className} {style}>
+ {@html logoMark}
+</div>
+
+<style>
+ div :global(svg) {
+ width: var(--size, 1rem);
+ height: var(--size, 1rem);
+ }
+</style>
* Preview button is shown only for HTML code blocks.
*/
export { default as CodeBlockActions } from './CodeBlockActions.svelte';
+
+/**
+ * **Logo** - Application brand mark
+ *
+ * Inline SVG of the application logo. Accepts styling via the standard
+ * `class` and `style` props and inherits color via `currentColor`.
+ */
+export { default as Logo } from './Logo.svelte';
+++ /dev/null
-<script lang="ts">
- import { goto } from '$app/navigation';
- import { page } from '$app/state';
- import { ActionIcon } from '$lib/components/app';
- import {
- ICON_STRIP_TRANSITION_DURATION,
- ICON_STRIP_TRANSITION_DELAY_MULTIPLIER,
- SIDEBAR_ACTIONS_ITEMS
- } from '$lib/constants';
- import { TooltipSide } from '$lib/enums';
- import { fade } from 'svelte/transition';
- import { circIn } from 'svelte/easing';
- import { onMount } from 'svelte';
- import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
-
- interface Props {
- sidebarOpen: boolean;
- onSearchClick: () => void;
- }
-
- let { sidebarOpen = false, onSearchClick }: Props = $props();
-
- const { handleKeydown } = useKeyboardShortcuts({ activateSearchMode: () => onSearchClick() });
-
- let initialized = $state(false);
- let showIcons = $derived(!sidebarOpen);
-
- showIcons = false;
-
- onMount(() => {
- showIcons = !sidebarOpen;
-
- setTimeout(() => {
- initialized = true;
- }, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
- });
-</script>
-
-<svelte:window onkeydown={handleKeydown} />
-
-<div
- class="hidden shrink-0 transition-[width] duration-200 ease-linear md:block {sidebarOpen
- ? 'w-0'
- : 'w-[calc(var(--sidebar-width-icon)+1.5rem)]'}"
-></div>
-<aside
- class="fixed top-0 bottom-0 left-0 z-10 hidden w-[calc(var(--sidebar-width-icon)+1.5rem)] flex-col items-center justify-between py-3 transition-opacity duration-200 ease-linear md:flex {sidebarOpen
- ? 'pointer-events-none opacity-0'
- : 'opacity-100'}"
->
- <div class="mt-12 flex flex-col items-center gap-1">
- {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
- {@const onclick = item.route ? () => goto(item.route!) : onSearchClick}
- {@const isActive = item.activeRouteId
- ? page.route.id === item.activeRouteId
- : item.activeRoutePrefix
- ? !!page.route.id?.startsWith(item.activeRoutePrefix)
- : false}
- {#if showIcons}
- <div
- in:fade={{
- duration: ICON_STRIP_TRANSITION_DURATION,
- delay: !initialized
- ? ICON_STRIP_TRANSITION_DELAY_MULTIPLIER + i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER
- : 0,
- easing: circIn
- }}
- >
- <ActionIcon
- icon={item.icon}
- tooltip={item.tooltip}
- tooltipSide={TooltipSide.RIGHT}
- size="lg"
- iconSize="h-4 w-4"
- class="h-9 w-9 rounded-full hover:bg-accent! {isActive
- ? 'bg-accent text-accent-foreground'
- : ''}"
- {onclick}
- />
- </div>
- {/if}
- {/each}
- </div>
-</aside>
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
- import { Trash2, Pencil, Pin, X } from '@lucide/svelte';
- import { Button } from '$lib/components/ui/button';
- import { DialogConfirmation } from '$lib/components/app';
- import SidebarNavigationActions from './SidebarNavigationActions.svelte';
- import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
- import { Checkbox } from '$lib/components/ui/checkbox';
- import Label from '$lib/components/ui/label/label.svelte';
- import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
- import * as Sidebar from '$lib/components/ui/sidebar';
- import Input from '$lib/components/ui/input/input.svelte';
- import { ROUTES } from '$lib/constants/routes';
- import { RouterService } from '$lib/services/router.service';
+ import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte';
import {
- conversationsStore,
- conversations,
- buildConversationTree
- } from '$lib/stores/conversations.svelte';
+ ActionIcon,
+ Logo,
+ SidebarNavigationConversationList,
+ SidebarNavigationActions
+ } from '$lib/components/app';
+ import { ROUTES } from '$lib/constants';
+ import { fade } from 'svelte/transition';
+
+ import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
+ import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
- import { getPreviewText } from '$lib/utils';
- import { APP_NAME } from '$lib/constants';
+ import { RouterService } from '$lib/services/router.service';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import { TooltipSide } from '$lib/enums';
+ import { device } from '$lib/stores/device.svelte';
+ import { circIn } from 'svelte/easing';
+
+ interface Props {
+ onSearchClick?: () => void;
+ }
+
+ let { onSearchClick = () => {} }: Props = $props();
+
+ const { handleKeydown } = useKeyboardShortcuts({ activateSearchMode: () => onSearchClick() });
- const sidebar = Sidebar.useSidebar();
+ let isExpandedMode = $state(false);
+ let hoveredTooltip = $state<string | null>(null);
+ let logoHovered = $state(false);
+
+ const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
+ const isOnMobile = $derived(isMobile.current);
+
+ function toggleExpandedMode() {
+ isExpandedMode = !isExpandedMode;
+ if (!isExpandedMode) {
+ hoveredTooltip = null;
+ }
+ }
+
+ $effect(() => {
+ if (!isExpandedMode) {
+ isSearchModeActive = false;
+ searchQuery = '';
+ cancelMobileCollapse();
+ }
+ });
+
+ // On mobile the dedicated /search route hides the sidebar (see the aside
+ // render guard below). Collapse it as we enter /search so it doesn't
+ // reappear expanded when the user navigates back via the back button.
+ $effect(() => {
+ if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) {
+ isExpandedMode = false;
+ }
+ });
let currentChatId = $derived(page.params.id);
let isSearchModeActive = $state(false);
let searchQuery = $state('');
- let showDeleteDialog = $state(false);
- let deleteWithForks = $state(false);
- let showEditDialog = $state(false);
- let selectedConversation = $state<DatabaseConversation | null>(null);
- let editedName = $state('');
- let selectedConversationNamePreview = $derived.by(() =>
- selectedConversation ? getPreviewText(selectedConversation.name) : ''
- );
let filteredConversations = $derived.by(() => {
if (isSearchModeActive) {
return conversations();
});
- let conversationTree = $derived(buildConversationTree(filteredConversations));
-
- let pinnedConversations = $derived.by(() => {
- return conversationTree.filter(({ conversation }) => conversation.pinned);
- });
-
- let unpinnedConversations = $derived.by(() => {
- return conversationTree.filter(({ conversation }) => !conversation.pinned);
- });
-
- let selectedConversationHasDescendants = $derived.by(() => {
- if (!selectedConversation) return false;
-
- const allConvs = conversations();
- const queue = [selectedConversation.id];
-
- while (queue.length > 0) {
- const parentId = queue.pop()!;
-
- for (const c of allConvs) {
- if (c.forkedFromConversationId === parentId) return true;
- }
- }
-
- return false;
- });
-
- async function handleDeleteConversation(id: string) {
- const conversation = conversations().find((conv) => conv.id === id);
- if (conversation) {
- selectedConversation = conversation;
- deleteWithForks = false;
- showDeleteDialog = true;
+ async function selectConversation(id: string) {
+ if (isMobile.current) {
+ scheduleMobileCollapse();
}
+ await goto(RouterService.chat(id));
}
async function handleEditConversation(id: string) {
const conversation = conversations().find((conv) => conv.id === id);
- if (conversation) {
- selectedConversation = conversation;
- editedName = conversation.name;
- showEditDialog = true;
- }
- }
-
- function handleConfirmDelete() {
- if (selectedConversation) {
- const convId = selectedConversation.id;
- const withForks = deleteWithForks;
- showDeleteDialog = false;
+ if (!conversation) return;
- setTimeout(() => {
- conversationsStore.deleteConversation(convId, {
- deleteWithForks: withForks
- });
- }, 100); // Wait for animation to finish
+ const newName = window.prompt('Rename conversation', conversation.name);
+ if (newName && newName.trim()) {
+ await conversationsStore.updateConversationName(id, newName.trim());
}
}
- function handleConfirmEdit() {
- if (!editedName.trim() || !selectedConversation) return;
-
- showEditDialog = false;
-
- conversationsStore.updateConversationName(selectedConversation.id, editedName);
- selectedConversation = null;
- }
-
- export function handleMobileSidebarItemClick() {
- if (sidebar.isMobile) {
- sidebar.toggle();
- }
- }
+ async function handleDeleteConversation(id: string) {
+ const conversation = conversations().find((conv) => conv.id === id);
+ if (!conversation) return;
- let chatSidebarActions: { activateSearch?: () => void } | undefined = $state();
- let openedForSearch = $state(false);
+ const confirmed = window.confirm(
+ `Delete "${conversation.name}"? This action cannot be undone.`
+ );
+ if (!confirmed) return;
- export function activateSearchMode() {
- if (!sidebar.open) {
- openedForSearch = true;
- }
- chatSidebarActions?.activateSearch?.();
+ await conversationsStore.deleteConversation(id, { deleteWithForks: false });
}
- function handleSearchDeactivated() {
- if (openedForSearch) {
- openedForSearch = false;
- sidebar.toggle();
- }
+ function handleStopGeneration(id: string) {
+ chatStore.stopGenerationForChat(id);
}
- $effect(() => {
- if (!sidebar.open) {
- isSearchModeActive = false;
- searchQuery = '';
- openedForSearch = false;
- }
- });
-
- export function editActiveConversation() {
- if (currentChatId) {
- const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
+ let innerWidth = $state(0);
+ let pendingCollapse = $state<ReturnType<typeof setTimeout> | null>(null);
- if (activeConversation) {
- const event = new CustomEvent('edit-active-conversation', {
- detail: { conversationId: currentChatId }
- });
- document.dispatchEvent(event);
- }
+ function scheduleMobileCollapse() {
+ if (pendingCollapse) {
+ clearTimeout(pendingCollapse);
}
+ pendingCollapse = setTimeout(() => {
+ isExpandedMode = false;
+ pendingCollapse = null;
+ }, 100);
}
- async function selectConversation(id: string) {
- if (isSearchModeActive) {
- isSearchModeActive = false;
- searchQuery = '';
+ function cancelMobileCollapse() {
+ if (pendingCollapse) {
+ clearTimeout(pendingCollapse);
+ pendingCollapse = null;
}
-
- handleMobileSidebarItemClick();
- await goto(RouterService.chat(id));
- }
-
- function handleStopGeneration(id: string) {
- chatStore.stopGenerationForChat(id);
}
</script>
-<div class="flex h-full flex-col">
- <ScrollArea class="h-full flex-1">
- <Sidebar.Header class="gap-4 bg-sidebar/50 p-3 backdrop-blur-lg md:pt-4 md:pb-2">
- <div class="flex items-center justify-between">
- <a href={ROUTES.START} onclick={handleMobileSidebarItemClick}>
- <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">
- {APP_NAME}
- </h1>
- </a>
-
- <Button
- class="rounded-full md:hidden"
- variant="ghost"
- size="icon"
- onclick={() => sidebar.toggle()}
- >
- <X class="h-4 w-4" />
- <span class="sr-only">Close sidebar</span>
- </Button>
+<svelte:window onkeydown={handleKeydown} bind:innerWidth />
+
+{#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))}
+ <aside
+ class={[
+ // Layout & positioning
+ 'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
+ // Dimensions & overflow
+ 'md:h-[calc(100dvh-1.125rem)]',
+ isExpandedMode &&
+ (device.isStandalone
+ ? 'h-[calc(100dvh-2rem)]'
+ : device.isIOSDevice
+ ? 'h-[calc(100dvh-0.5rem)]'
+ : 'h-[calc(100dvh-1rem)]'),
+ // Shape & depth
+ 'rounded-3xl md:rounded-2xl',
+ // Flex layout
+ 'flex flex-col justify-between',
+ // Transition
+ 'md:transition-[width,padding] duration-200 ease-out',
+ // Expanded state: width, surface, depth
+ isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl border-border shadow-md',
+ // Collapsed state
+ !isStripExpanded && 'md:w-12',
+ // Expanded mode flag (for mobile ::before overlay)
+ isExpandedMode && 'is-expanded'
+ ]}
+ >
+ <div class="px-2 flex items-center justify-between">
+ <div
+ role="button"
+ tabindex="0"
+ class="relative"
+ onmouseenter={() => (logoHovered = true)}
+ onmouseleave={() => (logoHovered = false)}
+ >
+ <ActionIcon
+ icon={!isExpandedMode && logoHovered && innerWidth > 768 ? PanelLeftOpen : Logo}
+ size="lg"
+ iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
+ class="{isExpandedMode
+ ? 'bg-muted! md:bg-foreground/5!'
+ : 'bg-transparent!'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
+ href={isExpandedMode ? ROUTES.START : undefined}
+ onclick={isExpandedMode ? undefined : toggleExpandedMode}
+ tooltip={isExpandedMode ? undefined : 'Open Sidebar'}
+ tooltipSide={TooltipSide.RIGHT}
+ ariaLabel={isExpandedMode ? 'Go to start' : 'Expand navigation'}
+ />
</div>
- <SidebarNavigationActions
- bind:this={chatSidebarActions}
- {handleMobileSidebarItemClick}
- bind:isSearchModeActive
- bind:searchQuery
- onSearchDeactivated={handleSearchDeactivated}
- />
- </Sidebar.Header>
-
- {#if !isSearchModeActive && pinnedConversations.length > 0}
- <Sidebar.Group class="p-0 px-4">
- <Sidebar.GroupLabel>
- <div class="flex items-center gap-1">
- <Pin class="h-3.5 w-3.5" />
- <span>Pinned</span>
- </div>
- </Sidebar.GroupLabel>
- <Sidebar.GroupContent>
- <Sidebar.Menu>
- {#each pinnedConversations as { conversation, depth } (conversation.id)}
- <Sidebar.MenuItem class="mb-1 p-0">
- <SidebarNavigationConversationItem
- conversation={{
- id: conversation.id,
- name: conversation.name,
- lastModified: conversation.lastModified,
- currNode: conversation.currNode,
- forkedFromConversationId: conversation.forkedFromConversationId,
- pinned: conversation.pinned
- }}
- {depth}
- isActive={currentChatId === conversation.id}
- onSelect={selectConversation}
- onEdit={handleEditConversation}
- onDelete={handleDeleteConversation}
- onStop={handleStopGeneration}
- />
- </Sidebar.MenuItem>
- {/each}
- </Sidebar.Menu>
- </Sidebar.GroupContent>
- </Sidebar.Group>
- {/if}
-
- <Sidebar.Group class="mt-2 h-[calc(100vh-21rem)] space-y-2 p-0 px-3">
- {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
- <Sidebar.GroupLabel>
- {isSearchModeActive ? 'Search results' : 'Recent conversations'}
- </Sidebar.GroupLabel>
+ {#if isExpandedMode || isOnMobile}
+ <div
+ class="flex items-center transition-all duration-150 ease-out {isMobile.current &&
+ !isExpandedMode
+ ? 'opacity-0 h-0!'
+ : ''}"
+ in:fade={{ duration: 150, easing: circIn, delay: 50 }}
+ out:fade={{ duration: 100 }}
+ >
+ <ActionIcon
+ icon={isMobile.current ? X : PanelLeftClose}
+ size="lg"
+ iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
+ class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
+ onclick={toggleExpandedMode}
+ tooltip="Close Sidebar"
+ tooltipSide={TooltipSide.LEFT}
+ ariaLabel="Collapse navigation"
+ />
+ </div>
{/if}
+ </div>
- <Sidebar.GroupContent>
- <Sidebar.Menu>
- {#each isSearchModeActive ? conversationTree : unpinnedConversations as { conversation, depth } (conversation.id)}
- <Sidebar.MenuItem class="mb-1 p-0">
- <SidebarNavigationConversationItem
- conversation={{
- id: conversation.id,
- name: conversation.name,
- lastModified: conversation.lastModified,
- currNode: conversation.currNode,
- forkedFromConversationId: conversation.forkedFromConversationId,
- pinned: conversation.pinned
- }}
- {depth}
- isActive={currentChatId === conversation.id}
- onSelect={selectConversation}
- onEdit={handleEditConversation}
- onDelete={handleDeleteConversation}
- onStop={handleStopGeneration}
- />
- </Sidebar.MenuItem>
- {/each}
-
- {#if (isSearchModeActive ? conversationTree : unpinnedConversations).length === 0}
- <div class="px-2 py-4 text-center">
- <p class="mb-4 p-4 text-sm text-muted-foreground">
- {searchQuery.length > 0
- ? 'No results found'
- : isSearchModeActive
- ? 'Start typing to see results'
- : 'No conversations yet'}
- </p>
- </div>
- {/if}
- </Sidebar.Menu>
- </Sidebar.GroupContent>
- </Sidebar.Group>
- </ScrollArea>
-</div>
+ <div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 overflow-y-auto">
+ <div
+ class="flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {isMobile.current
+ ? 'transition-[opacity,height] duration-200 ease-out'
+ : ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
+ in:fade={{ duration: 200 }}
+ out:fade={{ duration: 200 }}
+ >
+ <SidebarNavigationActions
+ isExpandedMode={innerWidth > 768 ? isExpandedMode : true}
+ class="px-2"
+ bind:isSearchModeActive
+ bind:searchQuery
+ onSearchDeactivated={() => {
+ isSearchModeActive = false;
+ searchQuery = '';
+ }}
+ onSearchClick={() => {
+ isExpandedMode = true;
+ isSearchModeActive = true;
+ }}
+ onNewChat={() => {
+ if (isMobile.current) {
+ scheduleMobileCollapse();
+ }
+ }}
+ />
+
+ {#if isExpandedMode || isOnMobile}
+ <SidebarNavigationConversationList
+ class="px-2"
+ {filteredConversations}
+ {currentChatId}
+ {isSearchModeActive}
+ {searchQuery}
+ onSelect={selectConversation}
+ onEdit={handleEditConversation}
+ onDelete={handleDeleteConversation}
+ onStop={handleStopGeneration}
+ />
+ {/if}
+ </div>
+ </div>
+ </aside>
+{/if}
-<DialogConfirmation
- bind:open={showDeleteDialog}
- title="Delete Conversation"
- description={selectedConversation
- ? `Are you sure you want to delete "${selectedConversationNamePreview}"? This action cannot be undone and will permanently remove all messages in this conversation.`
- : ''}
- confirmText="Delete"
- cancelText="Cancel"
- variant="destructive"
- icon={Trash2}
- onConfirm={handleConfirmDelete}
- onCancel={() => {
- showDeleteDialog = false;
- selectedConversation = null;
- }}
->
- {#if selectedConversationHasDescendants}
- <div class="flex items-center gap-2 py-2">
- <Checkbox id="delete-with-forks" bind:checked={deleteWithForks} />
+<style>
+ aside {
+ @media (max-width: 768px) {
+ --size: 1.125rem;
+ }
+ }
- <Label for="delete-with-forks" class="text-sm">Also delete all forked conversations</Label>
- </div>
- {/if}
-</DialogConfirmation>
+ @media (max-width: 768px) {
+ aside {
+ &:not(.is-expanded) {
+ pointer-events: none;
+ }
+ }
-<DialogConfirmation
- bind:open={showEditDialog}
- title="Edit Conversation Name"
- description=""
- confirmText="Save"
- cancelText="Cancel"
- icon={Pencil}
- onConfirm={handleConfirmEdit}
- onCancel={() => {
- showEditDialog = false;
- selectedConversation = null;
- }}
- onKeydown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.stopImmediatePropagation();
- handleConfirmEdit();
+ aside.is-expanded::before {
+ content: '';
+ position: fixed;
+ top: -0.5rem;
+ bottom: -0.25rem;
+ left: -0.5rem;
+ right: -0.5rem;
+ z-index: -1;
+ background: var(--background);
+ backdrop-filter: blur(1rem);
+ pointer-events: none;
}
- }}
->
- <Input
- class="text-foreground"
- placeholder="Enter a new name"
- type="text"
- bind:value={editedName}
- />
-</DialogConfirmation>
+ }
+</style>
<script lang="ts">
- import { KeyboardShortcutInfo } from '$lib/components/app';
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
+ import { Search } from '@lucide/svelte';
+ import { ActionIcon, KeyboardShortcutInfo, SearchInput } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
+ import {
+ ICON_STRIP_TRANSITION_DURATION,
+ ICON_STRIP_TRANSITION_DELAY_MULTIPLIER,
+ ROUTES,
+ SIDEBAR_ACTIONS_ITEMS
+ } from '$lib/constants';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+ import { TooltipSide } from '$lib/enums';
+ import { fade } from 'svelte/transition';
+ import { circIn } from 'svelte/easing';
+ import { onMount } from 'svelte';
import type { Component } from 'svelte';
- import { SearchInput } from '$lib/components/app';
- import { page } from '$app/state';
- import { SIDEBAR_ACTIONS_ITEMS } from '$lib/constants/ui';
interface Props {
- handleMobileSidebarItemClick: () => void;
+ class: string;
+ isExpandedMode: boolean;
isSearchModeActive: boolean;
searchQuery: string;
- isCancelAlwaysVisible?: boolean;
onSearchDeactivated?: () => void;
+ onSearchClick?: () => void;
+ onNewChat?: () => void;
}
let {
- handleMobileSidebarItemClick,
- isSearchModeActive = $bindable(),
- searchQuery = $bindable(),
- isCancelAlwaysVisible = false,
- onSearchDeactivated
+ class: className,
+ isExpandedMode = false,
+ isSearchModeActive = $bindable(false),
+ searchQuery = $bindable(''),
+ onSearchDeactivated,
+ onSearchClick,
+ onNewChat
}: Props = $props();
+ let initialized = $state(false);
+ let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
+ const isOnMobile = $derived(isMobile.current);
+
+ $effect(() => {
+ if (isSearchModeActive && searchInputRef) {
+ searchInputRef.focus();
+ }
+ });
+
+ onMount(() => {
+ showIcons = true;
+
+ setTimeout(() => {
+ initialized = true;
+ }, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
+ });
+
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
onSearchDeactivated?.();
}
- export function activateSearch() {
- isSearchModeActive = true;
- // Focus after Svelte renders the input
- queueMicrotask(() => searchInputRef?.focus());
+ function isItemActive(item: {
+ activeRouteId?: string;
+ activeRoutePrefix?: string;
+ activeUrlIncludes?: string;
+ }): boolean {
+ if (item.activeRouteId) {
+ return page.route.id === item.activeRouteId;
+ }
+
+ if (item.activeRoutePrefix) {
+ return !!page.route.id?.startsWith(item.activeRoutePrefix);
+ }
+
+ if (item.activeUrlIncludes) {
+ return page.url?.hash?.includes(item.activeUrlIncludes) ?? false;
+ }
+
+ return false;
}
</script>
<IconComponent class="h-4 w-4" />
{/snippet}
-<div class="my-1 space-y-1">
- {#if isSearchModeActive}
+{#if isSearchModeActive}
+ <div class="px-4 my-2">
<SearchInput
bind:value={searchQuery}
bind:ref={searchInputRef}
onClose={handleSearchModeDeactivate}
onKeyDown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
placeholder="Search conversations..."
- {isCancelAlwaysVisible}
/>
- {:else}
- {#each SIDEBAR_ACTIONS_ITEMS as item (item.route)}
- {#if !item.route}
- <Button
- class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100"
- onclick={activateSearch}
- variant="ghost"
- >
- <div class="flex items-center gap-2">
- {@render itemIcon(item.icon)}
-
- {item.tooltip}
- </div>
-
- {#if item.keys}
- <KeyboardShortcutInfo keys={item.keys} />
- {/if}
- </Button>
- {:else}
- <Button
- class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {(item.activeRouteId &&
- page.route.id === item.activeRouteId) ||
- (item.activeRoutePrefix && page.route.id?.startsWith(item.activeRoutePrefix))
- ? 'bg-accent text-accent-foreground'
- : ''}"
- href={item.route}
- onclick={handleMobileSidebarItemClick}
- variant="ghost"
- >
- <div class="flex items-center gap-2">
- {@render itemIcon(item.icon)}
-
- {item.tooltip}
- </div>
-
- {#if item.keys}
- <KeyboardShortcutInfo keys={item.keys} />
- {/if}
- </Button>
+ </div>
+{:else if isExpandedMode || isOnMobile}
+ <div
+ class="{className} flex flex-col gap-5 md:gap-1 mt-2 md:mt-0 {!isExpandedMode && isOnMobile
+ ? 'hidden pointer-events-none'
+ : ''}"
+ >
+ {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
+ {@const isActive = isItemActive(item)}
+ {@const isSearchOnMobile = item.icon === Search && isMobile.current}
+ {@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
+ {@const itemOnClick = item.route
+ ? () => {
+ onNewChat?.();
+ goto(item.route!);
+ }
+ : isSearchOnMobile
+ ? undefined
+ : onSearchClick}
+ {@const itemTransition = {
+ duration: ICON_STRIP_TRANSITION_DURATION,
+ delay: !initialized
+ ? ICON_STRIP_TRANSITION_DELAY_MULTIPLIER + i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER
+ : 0,
+ easing: circIn
+ }}
+
+ {#if showIcons}
+ <div transition:fade={itemTransition}>
+ <Button
+ class="w-full min-w-9 justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isActive
+ ? 'bg-accent text-accent-foreground'
+ : ''}"
+ href={itemHref}
+ onclick={itemOnClick}
+ variant="ghost"
+ size="default"
+ >
+ <span class="flex min-w-0 items-center px-0.5 gap-2">
+ {@render itemIcon(item.icon)}
+
+ {#if showIcons}
+ <span
+ in:fade={{ duration: 150, easing: circIn, delay: 50 }}
+ out:fade={{ duration: 100 }}
+ class="min-w-0 truncate">{item.tooltip}</span
+ >
+ {/if}
+ </span>
+
+ {#if item.keys}
+ <KeyboardShortcutInfo keys={item.keys} />
+ {/if}
+ </Button>
+ </div>
+ {/if}
+ {/each}
+ </div>
+{:else}
+ <div class="{className} flex-col gap-1 hidden md:flex">
+ {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
+ {@const isActive = isItemActive(item)}
+ {@const isSearchOnMobile = item.icon === Search && isMobile.current}
+ {@const itemOnClick = item.route
+ ? () => {
+ onNewChat?.();
+ goto(item.route!);
+ }
+ : isSearchOnMobile
+ ? undefined
+ : onSearchClick}
+ {@const itemTransition = {
+ duration: ICON_STRIP_TRANSITION_DURATION,
+ delay: !initialized
+ ? ICON_STRIP_TRANSITION_DELAY_MULTIPLIER + i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER
+ : 0,
+ easing: circIn
+ }}
+
+ {#if showIcons}
+ <div transition:fade={itemTransition}>
+ <ActionIcon
+ icon={item.icon}
+ tooltip={item.tooltip}
+ tooltipSide={TooltipSide.RIGHT}
+ size="lg"
+ iconSize="h-4 w-4"
+ class="h-9 w-9 rounded-full hover:bg-accent! {isActive
+ ? 'bg-accent text-accent-foreground'
+ : ''}"
+ onclick={itemOnClick}
+ />
+ </div>
{/if}
{/each}
- {/if}
-</div>
+ </div>
+{/if}
--- /dev/null
+<script lang="ts">
+ import { Pin } from '@lucide/svelte';
+ import { buildConversationTree } from '$lib/stores/conversations.svelte';
+ import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
+ import SidebarNavigationSearchResults from './SidebarNavigationSearchResults.svelte';
+
+ interface Props {
+ class: string;
+ filteredConversations: DatabaseConversation[];
+ currentChatId: string | undefined;
+ isSearchModeActive: boolean;
+ searchQuery: string;
+ onSelect: (id: string) => void;
+ onEdit: (id: string) => void;
+ onDelete: (id: string) => void;
+ onStop: (id: string) => void;
+ }
+
+ let {
+ class: className,
+ filteredConversations,
+ currentChatId,
+ isSearchModeActive,
+ searchQuery,
+ onSelect,
+ onEdit,
+ onDelete,
+ onStop
+ }: Props = $props();
+
+ let conversationTree = $derived(buildConversationTree(filteredConversations));
+
+ let pinnedConversations = $derived(
+ conversationTree.filter(({ conversation }) => conversation.pinned)
+ );
+
+ let unpinnedConversations = $derived(
+ conversationTree.filter(({ conversation }) => !conversation.pinned)
+ );
+
+ const recentEmptyMessage = $derived(
+ searchQuery.length > 0 ? 'No results found' : 'No conversations yet'
+ );
+</script>
+
+{#if isSearchModeActive}
+ <SidebarNavigationSearchResults
+ class={className}
+ {searchQuery}
+ {filteredConversations}
+ {currentChatId}
+ {onSelect}
+ {onEdit}
+ {onDelete}
+ {onStop}
+ />
+{:else}
+ {#if pinnedConversations.length > 0}
+ <div class="py-2 flex whitespace-nowrap {className}">
+ <div
+ class="text-muted-foreground inline-flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium gap-1"
+ >
+ <Pin class="h-3.5 w-3.5" />
+
+ <span>Pinned</span>
+ </div>
+ </div>
+
+ <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1 {className}">
+ {#each pinnedConversations as { conversation, depth } (conversation.id)}
+ <li class="group/item relative mb-1 p-0">
+ <SidebarNavigationConversationItem
+ conversation={{
+ id: conversation.id,
+ name: conversation.name,
+ lastModified: conversation.lastModified,
+ currNode: conversation.currNode,
+ forkedFromConversationId: conversation.forkedFromConversationId,
+ pinned: conversation.pinned
+ }}
+ {depth}
+ isActive={currentChatId === conversation.id}
+ {onSelect}
+ {onEdit}
+ {onDelete}
+ {onStop}
+ />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+
+ <div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-2 whitespace-nowrap {className}">
+ {#if filteredConversations.length > 0}
+ <div
+ class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
+ >
+ Recent conversations
+ </div>
+ {/if}
+
+ <div class="min-h-0 flex-1 md:overflow-y-auto">
+ <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1">
+ {#each unpinnedConversations as { conversation, depth } (conversation.id)}
+ <li class="group/item relative mb-1 p-0">
+ <SidebarNavigationConversationItem
+ conversation={{
+ id: conversation.id,
+ name: conversation.name,
+ lastModified: conversation.lastModified,
+ currNode: conversation.currNode,
+ forkedFromConversationId: conversation.forkedFromConversationId,
+ pinned: conversation.pinned
+ }}
+ {depth}
+ isActive={currentChatId === conversation.id}
+ {onSelect}
+ {onEdit}
+ {onDelete}
+ {onStop}
+ />
+ </li>
+ {/each}
+
+ {#if unpinnedConversations.length === 0}
+ <li class="px-2 py-4 text-center">
+ <p class="mb-4 p-4 text-sm text-muted-foreground">
+ {recentEmptyMessage}
+ </p>
+ </li>
+ {/if}
+ </ul>
+ </div>
+ </div>
+{/if}
}: Props = $props();
</script>
-<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
+<div class="mb-4 px-2 {className}">
+ <SearchInput bind:value {placeholder} {onInput} />
+</div>
--- /dev/null
+<script lang="ts">
+ import { buildConversationTree } from '$lib/stores/conversations.svelte';
+ import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
+
+ interface Props {
+ class?: string;
+ searchQuery: string;
+ filteredConversations: DatabaseConversation[];
+ currentChatId: string | undefined;
+ onSelect: (id: string) => void;
+ onEdit: (id: string) => void;
+ onDelete: (id: string) => void;
+ onStop: (id: string) => void;
+ }
+
+ let {
+ class: className = '',
+ searchQuery,
+ filteredConversations,
+ currentChatId,
+ onSelect,
+ onEdit,
+ onDelete,
+ onStop
+ }: Props = $props();
+
+ let tree = $derived(buildConversationTree(filteredConversations));
+
+ const hasQuery = $derived(searchQuery.trim().length > 0);
+ const showHeader = $derived(hasQuery && filteredConversations.length > 0);
+
+ const emptyMessage = $derived(hasQuery ? 'No results found' : 'Start typing to see results');
+</script>
+
+<div class="flex min-h-0 flex-1 flex-col gap-2 whitespace-nowrap {className}">
+ {#if showHeader}
+ <div
+ class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
+ >
+ Search results
+ </div>
+ {/if}
+
+ <div class="min-h-0 flex-1 overflow-y-auto">
+ <ul class="flex w-full min-w-0 flex-col gap-1">
+ {#each tree as { conversation, depth } (conversation.id)}
+ <li class="group/item relative mb-1 p-0">
+ <SidebarNavigationConversationItem
+ conversation={{
+ id: conversation.id,
+ name: conversation.name,
+ lastModified: conversation.lastModified,
+ currNode: conversation.currNode,
+ forkedFromConversationId: conversation.forkedFromConversationId,
+ pinned: conversation.pinned
+ }}
+ {depth}
+ isActive={currentChatId === conversation.id}
+ {onSelect}
+ {onEdit}
+ {onDelete}
+ {onStop}
+ />
+ </li>
+ {/each}
+
+ {#if tree.length === 0}
+ <li class="px-2 py-4 text-center">
+ <p class="mb-4 p-4 text-sm text-muted-foreground">
+ {emptyMessage}
+ </p>
+ </li>
+ {/if}
+ </ul>
+ </div>
+</div>
* ```
*/
export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
-
-/**
- * **DesktopIconStrip** - Fixed icon strip for desktop sidebar
- *
- * Vertical icon strip shown on desktop when the sidebar is collapsed.
- * Contains navigation shortcuts for new chat, search, MCP, import/export, and settings.
- */
-export { default as DesktopIconStrip } from './DesktopIconStrip.svelte';
-
/**
* **SidebarNavigation** - Sidebar with actions menu and conversation list
*
*/
export { default as SidebarNavigation } from './SidebarNavigation/SidebarNavigation.svelte';
-/**
- * Action buttons for sidebar header. Contains new chat button, settings button,
- * and delete all conversations button. Manages dialog states for settings and
- * delete confirmation.
- */
-export { default as SidebarNavigationActions } from './SidebarNavigation/SidebarNavigationActions.svelte';
-
/**
* Single conversation item in sidebar. Displays conversation title (truncated),
* last message preview, and timestamp. Shows context menu on right-click with
*/
export { default as SidebarNavigationConversationItem } from './SidebarNavigation/SidebarNavigationConversationItem.svelte';
+/**
+ * **SidebarNavigationConversationList** - Grouped conversation list
+ *
+ * Pure-presentational list of conversations. Splits items into a Pinned
+ * section (when not in search mode) and a Recent Conversations / Search
+ * Results section with the unpinned items. Item selection, edit, delete,
+ * and stop-generation are delegated to the caller via callbacks.
+ *
+ * @example
+ * ```svelte
+ * <SidebarNavigationConversationList
+ * {filteredConversations}
+ * {currentChatId}
+ * {isSearchModeActive}
+ * {searchQuery}
+ * onSelect={...}
+ * onEdit={...}
+ * onDelete={...}
+ * onStop={...}
+ * />
+ * ```
+ */
+export { default as SidebarNavigationConversationList } from './SidebarNavigation/SidebarNavigationConversationList.svelte';
+export { default as SidebarNavigationActions } from './SidebarNavigation/SidebarNavigationActions.svelte';
+
+/**
+ * **SidebarNavigationSearchResults** - Filtered conversation list for search.
+ *
+ * Pure-presentational rendering of the search-mode subtree: "Search results"
+ * header, the matching items rendered through {@link SidebarNavigationConversationItem},
+ * and contextual empty-state messages. Used both inline inside
+ * {@link SidebarNavigationConversationList} (when search mode is active in the
+ * sidebar) and as the body of the mobile `/search` route.
+ *
+ * The caller is expected to provide an already-filtered list via
+ * `filteredConversations` and a `searchQuery` for the empty-state messages.
+ *
+ * @example
+ * ```svelte
+ * <SidebarNavigationSearchResults
+ * {searchQuery}
+ * {filteredConversations}
+ * {currentChatId}
+ * onSelect={...}
+ * onEdit={...}
+ * onDelete={...}
+ * onStop={...}
+ * />
+ * ```
+ */
+export { default as SidebarNavigationSearchResults } from './SidebarNavigation/SidebarNavigationSearchResults.svelte';
+
/**
* Search input for filtering conversations in sidebar. Filters conversation
* list by title as user types. Shows clear button when query is not empty.
});
</script>
-<div
- class="mx-auto flex h-full max-h-[100dvh] w-full flex-col overflow-y-auto md:pl-8"
- in:fade={{ duration: 150 }}
->
+<div class="mx-auto flex h-full w-full flex-col md:pl-8" in:fade={{ duration: 150 }}>
<div class="flex flex-1 flex-col gap-4 md:flex-row">
<SettingsChatDesktopSidebar
sections={SETTINGS_CHAT_SECTIONS}
let { sections, isActive, getHref, onSectionChange }: Props = $props();
</script>
-<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-10 pb-4 md:flex">
- <div class="flex items-center gap-2 pb-10">
- <Settings class="h-6 w-6" />
- <h1 class="text-2xl font-semibold">Settings</h1>
+<div class="sticky top-2 hidden w-64 flex-col self-start bg-background py-4 md:flex gap-6">
+ <div class="flex items-center gap-2 py-2">
+ <Settings class="h-5 w-5 md:h-6 md:w-6" />
+
+ <h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
+
<nav class="space-y-1">
{#each sections as section (section.title)}
{#if getHref}
<script lang="ts">
- import { Plus } from '@lucide/svelte';
+ import { X, Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
- import { McpServerCard, McpServerCardSkeleton } from '$lib/components/app/mcp';
+ import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { HealthCheckStatus } from '$lib/enums';
+ import { ROUTES } from '$lib/constants';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import McpLogo from '../mcp/McpLogo.svelte';
+ import { browser } from '$app/environment';
import { page } from '$app/state';
- import { replaceState } from '$app/navigation';
+ import { goto, replaceState } from '$app/navigation';
interface Props {
class?: string;
let initialLoadComplete = $state(false);
let isAddingServer = $state(false);
+ let previousRouteId = $state<string | null>(null);
+
+ $effect(() => {
+ const currentId = page.route.id;
+ return () => {
+ previousRouteId = currentId;
+ };
+ });
+
+ function handleClose() {
+ const prevIsMcpServers = previousRouteId === '/mcp-servers';
+ if (browser && window.history.length > 1 && !prevIsMcpServers) {
+ history.back();
+ } else {
+ goto(ROUTES.START);
+ }
+ }
+
onMount(() => {
if (page.url.searchParams.has('add')) {
isAddingServer = true;
});
</script>
-<div in:fade={{ duration: 150 }} class="h-full max-h-[100dvh] overflow-y-auto">
- <div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
- <McpLogo class="h-5 w-5 md:h-6 md:w-6" />
-
- <h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
+<div in:fade={{ duration: 150 }}>
+ <div class="fixed top-4.5 right-4 z-50 md:hidden">
+ <ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
</div>
- <div class="sticky top-0 z-10 mt-4 flex items-start gap-4 p-4 md:justify-end md:px-8">
- <Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
+ <div
+ class="sticky top-0 z-10 mt-4 mb-2 flex items-start gap-4 md:p-4 p-0 px-4 md:justify-between md:px-8"
+ >
+ <div class="flex items-center gap-2">
+ <McpLogo class="h-5 w-5 md:h-6 md:w-6" />
+
+ <h1 class="text-lg font-semibold md:text-2xl">MCP Servers</h1>
+ </div>
+
+ <Button
+ variant="outline"
+ size="lg"
+ class="shrink-0 fixed md:static bottom-6 right-6"
+ onclick={() => (isAddingServer = true)}
+ >
<Plus class="h-4 w-4" />
Add New Server
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
- lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4',
'icon-lg': 'size-10',
icon: 'size-9',
'icon-sm': 'size-5 rounded-sm'
+++ /dev/null
-export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
-export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
-export const SIDEBAR_MIN_WIDTH = '18rem';
-export const SIDEBAR_MAX_WIDTH = '32rem';
-export const SIDEBAR_WIDTH_MOBILE = '18rem';
-export const SIDEBAR_WIDTH_ICON = '3rem';
-export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
+++ /dev/null
-import { isMobile } from '$lib/stores/viewport.svelte.js';
-import { getContext, setContext } from 'svelte';
-import { SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_MIN_WIDTH } from './constants.js';
-
-type Getter<T> = () => T;
-
-export type SidebarStateProps = {
- /**
- * A getter function that returns the current open state of the sidebar.
- * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
- * component.
- */
- open: Getter<boolean>;
-
- /**
- * A function that sets the open state of the sidebar. To support `bind:open`, we need
- * a source of truth for changing the open state to ensure it will be synced throughout
- * the sub-components and any `bind:` references.
- */
- setOpen: (open: boolean) => void;
-};
-
-class SidebarState {
- readonly props: SidebarStateProps;
- open = $derived.by(() => this.props.open());
- openMobile = $state(false);
- sidebarWidth = $state(SIDEBAR_MIN_WIDTH);
- isResizing = $state(false);
- setOpen: SidebarStateProps['setOpen'];
- state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
-
- constructor(props: SidebarStateProps) {
- this.setOpen = props.setOpen;
- this.props = props;
- }
-
- // Convenience getter for checking if the sidebar is mobile
- // without this, we would need to use `sidebar.isMobile.current` everywhere
- get isMobile() {
- return isMobile.current;
- }
-
- // Event handler to apply to the `<svelte:window>`
- handleShortcutKeydown = (e: KeyboardEvent) => {
- if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
- e.preventDefault();
- this.toggle();
- }
- };
-
- setOpenMobile = (value: boolean) => {
- this.openMobile = value;
- };
-
- toggle = () => {
- this.setOpen(!this.open);
- };
-}
-
-const SYMBOL_KEY = 'scn-sidebar';
-
-/**
- * Instantiates a new `SidebarState` instance and sets it in the context.
- *
- * @param props The constructor props for the `SidebarState` class.
- * @returns The `SidebarState` instance.
- */
-export function setSidebar(props: SidebarStateProps): SidebarState {
- return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
-}
-
-/**
- * Retrieves the `SidebarState` instance from the context. This is a class instance,
- * so you cannot destructure it.
- * @returns The `SidebarState` instance.
- */
-export function useSidebar(): SidebarState {
- return getContext(Symbol.for(SYMBOL_KEY));
-}
+++ /dev/null
-import { useSidebar } from './context.svelte.js';
-import Content from './sidebar-content.svelte';
-import Footer from './sidebar-footer.svelte';
-import GroupAction from './sidebar-group-action.svelte';
-import GroupContent from './sidebar-group-content.svelte';
-import GroupLabel from './sidebar-group-label.svelte';
-import Group from './sidebar-group.svelte';
-import Header from './sidebar-header.svelte';
-import Input from './sidebar-input.svelte';
-import Inset from './sidebar-inset.svelte';
-import MenuAction from './sidebar-menu-action.svelte';
-import MenuBadge from './sidebar-menu-badge.svelte';
-import MenuButton from './sidebar-menu-button.svelte';
-import MenuItem from './sidebar-menu-item.svelte';
-import MenuSkeleton from './sidebar-menu-skeleton.svelte';
-import MenuSubButton from './sidebar-menu-sub-button.svelte';
-import MenuSubItem from './sidebar-menu-sub-item.svelte';
-import MenuSub from './sidebar-menu-sub.svelte';
-import Menu from './sidebar-menu.svelte';
-import Provider from './sidebar-provider.svelte';
-import Rail from './sidebar-rail.svelte';
-import Separator from './sidebar-separator.svelte';
-import Trigger from './sidebar-trigger.svelte';
-import Root from './sidebar.svelte';
-
-export {
- Content,
- Footer,
- Group,
- GroupAction,
- GroupContent,
- GroupLabel,
- Header,
- Input,
- Inset,
- Menu,
- MenuAction,
- MenuBadge,
- MenuButton,
- MenuItem,
- MenuSkeleton,
- MenuSub,
- MenuSubButton,
- MenuSubItem,
- Provider,
- Rail,
- Root,
- Separator,
- //
- Root as Sidebar,
- Content as SidebarContent,
- Footer as SidebarFooter,
- Group as SidebarGroup,
- GroupAction as SidebarGroupAction,
- GroupContent as SidebarGroupContent,
- GroupLabel as SidebarGroupLabel,
- Header as SidebarHeader,
- Input as SidebarInput,
- Inset as SidebarInset,
- Menu as SidebarMenu,
- MenuAction as SidebarMenuAction,
- MenuBadge as SidebarMenuBadge,
- MenuButton as SidebarMenuButton,
- MenuItem as SidebarMenuItem,
- MenuSkeleton as SidebarMenuSkeleton,
- MenuSub as SidebarMenuSub,
- MenuSubButton as SidebarMenuSubButton,
- MenuSubItem as SidebarMenuSubItem,
- Provider as SidebarProvider,
- Rail as SidebarRail,
- Separator as SidebarSeparator,
- Trigger as SidebarTrigger,
- Trigger,
- useSidebar
-};
+++ /dev/null
-<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-content"
- data-sidebar="content"
- class={cn(
- 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
- className
- )}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-footer"
- data-sidebar="footer"
- class={cn('flex flex-col gap-2 p-3', className)}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { Snippet } from 'svelte';
- import type { HTMLButtonAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- child,
- ...restProps
- }: WithElementRef<HTMLButtonAttributes> & {
- child?: Snippet<[{ props: Record<string, unknown> }]>;
- } = $props();
-
- const mergedProps = $derived({
- class: cn(
- 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
- // Increases the hit area of the button on mobile.
- 'after:absolute after:-inset-2 md:after:hidden',
- 'group-data-[collapsible=icon]:hidden',
- className
- ),
- 'data-slot': 'sidebar-group-action',
- 'data-sidebar': 'group-action',
- ...restProps
- });
-</script>
-
-{#if child}
- {@render child({ props: mergedProps })}
-{:else}
- <button bind:this={ref} {...mergedProps}>
- {@render children?.()}
- </button>
-{/if}
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-group-content"
- data-sidebar="group-content"
- class={cn('w-full text-sm', className)}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { Snippet } from 'svelte';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- children,
- child,
- class: className,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> & {
- child?: Snippet<[{ props: Record<string, unknown> }]>;
- } = $props();
-
- const mergedProps = $derived({
- class: cn(
- 'text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
- 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
- className
- ),
- 'data-slot': 'sidebar-group-label',
- 'data-sidebar': 'group-label',
- ...restProps
- });
-</script>
-
-{#if child}
- {@render child({ props: mergedProps })}
-{:else}
- <div bind:this={ref} {...mergedProps}>
- {@render children?.()}
- </div>
-{/if}
+++ /dev/null
-<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-group"
- data-sidebar="group"
- class={cn('relative flex w-full min-w-0 flex-col p-2', className)}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import type { HTMLAttributes } from 'svelte/elements';
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-header"
- data-sidebar="header"
- class={cn('flex flex-col gap-2 p-2', className)}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import type { ComponentProps } from 'svelte';
- import { Input } from '$lib/components/ui/input/index.js';
- import { cn } from '$lib/components/ui/utils.js';
-
- let {
- ref = $bindable(null),
- value = $bindable(''),
- class: className,
- ...restProps
- }: ComponentProps<typeof Input> = $props();
-</script>
-
-<Input
- bind:ref
- bind:value
- data-slot="sidebar-input"
- data-sidebar="input"
- class={cn('h-8 w-full bg-background shadow-none', className)}
- {...restProps}
-/>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
-</script>
-
-<main
- bind:this={ref}
- data-slot="sidebar-inset"
- class={cn(
- 'relative flex w-full flex-1 flex-col',
- 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
- className
- )}
- {...restProps}
->
- {@render children?.()}
-</main>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { Snippet } from 'svelte';
- import type { HTMLButtonAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- showOnHover = false,
- children,
- child,
- ...restProps
- }: WithElementRef<HTMLButtonAttributes> & {
- child?: Snippet<[{ props: Record<string, unknown> }]>;
- showOnHover?: boolean;
- } = $props();
-
- const mergedProps = $derived({
- class: cn(
- 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
- // Increases the hit area of the button on mobile.
- 'after:absolute after:-inset-2 md:after:hidden',
- 'peer-data-[size=sm]/menu-button:top-1',
- 'peer-data-[size=default]/menu-button:top-1.5',
- 'peer-data-[size=lg]/menu-button:top-2.5',
- 'group-data-[collapsible=icon]:hidden',
- showOnHover &&
- 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
- className
- ),
- 'data-slot': 'sidebar-menu-action',
- 'data-sidebar': 'menu-action',
- ...restProps
- });
-</script>
-
-{#if child}
- {@render child({ props: mergedProps })}
-{:else}
- <button bind:this={ref} {...mergedProps}>
- {@render children?.()}
- </button>
-{/if}
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-menu-badge"
- data-sidebar="menu-badge"
- class={cn(
- 'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none',
- 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
- 'peer-data-[size=sm]/menu-button:top-1',
- 'peer-data-[size=default]/menu-button:top-1.5',
- 'peer-data-[size=lg]/menu-button:top-2.5',
- 'group-data-[collapsible=icon]:hidden',
- className
- )}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts" module>
- import { tv, type VariantProps } from 'tailwind-variants';
-
- export const sidebarMenuButtonVariants = tv({
- base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md py-2 px-1 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
- variants: {
- variant: {
- default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
- outline:
- 'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
- },
- size: {
- default: 'h-8 text-sm',
- sm: 'h-7 text-xs',
- lg: 'group-data-[collapsible=icon]:p-0! h-12 text-sm'
- }
- },
- defaultVariants: {
- variant: 'default',
- size: 'default'
- }
- });
-
- export type SidebarMenuButtonVariant = VariantProps<typeof sidebarMenuButtonVariants>['variant'];
- export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>['size'];
-</script>
-
-<script lang="ts">
- import * as Tooltip from '$lib/components/ui/tooltip/index.js';
- import {
- cn,
- type WithElementRef,
- type WithoutChildrenOrChild
- } from '$lib/components/ui/utils.js';
- import { mergeProps } from 'bits-ui';
- import type { ComponentProps, Snippet } from 'svelte';
- import type { HTMLAttributes } from 'svelte/elements';
- import { useSidebar } from './context.svelte.js';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- child,
- variant = 'default',
- size = 'default',
- isActive = false,
- tooltipContent,
- tooltipContentProps,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
- isActive?: boolean;
- variant?: SidebarMenuButtonVariant;
- size?: SidebarMenuButtonSize;
- tooltipContent?: Snippet | string;
- tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
- child?: Snippet<[{ props: Record<string, unknown> }]>;
- } = $props();
-
- const sidebar = useSidebar();
-
- const buttonProps = $derived({
- class: cn(sidebarMenuButtonVariants({ variant, size }), className),
- 'data-slot': 'sidebar-menu-button',
- 'data-sidebar': 'menu-button',
- 'data-size': size,
- 'data-active': isActive,
- ...restProps
- });
-</script>
-
-{#snippet Button({ props }: { props?: Record<string, unknown> })}
- {@const mergedProps = mergeProps(buttonProps, props)}
- {#if child}
- {@render child({ props: mergedProps })}
- {:else}
- <button bind:this={ref} {...mergedProps}>
- {@render children?.()}
- </button>
- {/if}
-{/snippet}
-
-{#if !tooltipContent}
- {@render Button({})}
-{:else}
- <Tooltip.Root>
- <Tooltip.Trigger>
- {#snippet child({ props })}
- {@render Button({ props })}
- {/snippet}
- </Tooltip.Trigger>
-
- <Tooltip.Content
- side="right"
- align="center"
- hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
- {...tooltipContentProps}
- >
- {#if typeof tooltipContent === 'string'}
- {tooltipContent}
- {:else if tooltipContent}
- {@render tooltipContent()}
- {/if}
- </Tooltip.Content>
- </Tooltip.Root>
-{/if}
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
-</script>
-
-<li
- bind:this={ref}
- data-slot="sidebar-menu-item"
- data-sidebar="menu-item"
- class={cn('group/menu-item relative', className)}
- {...restProps}
->
- {@render children?.()}
-</li>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import { Skeleton } from '$lib/components/ui/skeleton/index.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- showIcon = false,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLElement>> & {
- showIcon?: boolean;
- } = $props();
-
- // Random width between 50% and 90%
- const width = `${Math.floor(Math.random() * 40) + 50}%`;
-</script>
-
-<div
- bind:this={ref}
- data-slot="sidebar-menu-skeleton"
- data-sidebar="menu-skeleton"
- class={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
- {...restProps}
->
- {#if showIcon}
- <Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
- {/if}
- <Skeleton
- class="h-4 max-w-(--skeleton-width) flex-1"
- data-sidebar="menu-skeleton-text"
- style="--skeleton-width: {width};"
- />
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { Snippet } from 'svelte';
- import type { HTMLAnchorAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- children,
- child,
- class: className,
- size = 'md',
- isActive = false,
- ...restProps
- }: WithElementRef<HTMLAnchorAttributes> & {
- child?: Snippet<[{ props: Record<string, unknown> }]>;
- size?: 'sm' | 'md';
- isActive?: boolean;
- } = $props();
-
- const mergedProps = $derived({
- class: cn(
- 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
- 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
- size === 'sm' && 'text-xs',
- size === 'md' && 'text-sm',
- 'group-data-[collapsible=icon]:hidden',
- className
- ),
- 'data-slot': 'sidebar-menu-sub-button',
- 'data-sidebar': 'menu-sub-button',
- 'data-size': size,
- 'data-active': isActive,
- ...restProps
- });
-</script>
-
-{#if child}
- {@render child({ props: mergedProps })}
-{:else}
- <a bind:this={ref} {...mergedProps}>
- {@render children?.()}
- </a>
-{/if}
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- children,
- class: className,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
-</script>
-
-<li
- bind:this={ref}
- data-slot="sidebar-menu-sub-item"
- data-sidebar="menu-sub-item"
- class={cn('group/menu-sub-item relative', className)}
- {...restProps}
->
- {@render children?.()}
-</li>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
-</script>
-
-<ul
- bind:this={ref}
- data-slot="sidebar-menu-sub"
- data-sidebar="menu-sub"
- class={cn(
- 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
- 'group-data-[collapsible=icon]:hidden',
- className
- )}
- {...restProps}
->
- {@render children?.()}
-</ul>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
-</script>
-
-<ul
- bind:this={ref}
- data-slot="sidebar-menu"
- data-sidebar="menu"
- class={cn('flex w-full min-w-0 flex-col gap-1', className)}
- {...restProps}
->
- {@render children?.()}
-</ul>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
- import {
- SIDEBAR_COOKIE_MAX_AGE,
- SIDEBAR_COOKIE_NAME,
- SIDEBAR_MIN_WIDTH,
- SIDEBAR_MAX_WIDTH,
- SIDEBAR_WIDTH_ICON
- } from './constants.js';
- import { setSidebar } from './context.svelte.js';
-
- let {
- ref = $bindable(null),
- open = $bindable(true),
- onOpenChange = () => {},
- class: className,
- style,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- } = $props();
-
- const sidebar = setSidebar({
- open: () => open,
- setOpen: (value: boolean) => {
- open = value;
- onOpenChange(value);
-
- // This sets the cookie to keep the sidebar state.
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
- }
- });
-</script>
-
-<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
-
-<div
- data-slot="sidebar-wrapper"
- style="--sidebar-width: {sidebar.sidebarWidth}; --sidebar-min-width: {SIDEBAR_MIN_WIDTH}; --sidebar-max-width: {SIDEBAR_MAX_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
- class={cn(
- 'group/sidebar-wrapper flex flex-col h-dvh w-full has-data-[variant=inset]:bg-sidebar',
- className
- )}
- bind:this={ref}
- {...restProps}
->
- {@render children?.()}
-</div>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
- import { useSidebar } from './context.svelte.js';
-
- let {
- ref = $bindable(null),
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
-
- const sidebar = useSidebar();
-</script>
-
-<button
- bind:this={ref}
- data-sidebar="rail"
- data-slot="sidebar-rail"
- aria-label="Toggle Sidebar"
- tabIndex={-1}
- onclick={sidebar.toggle}
- title="Toggle Sidebar"
- class={cn(
- 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] hover:after:bg-sidebar-border sm:flex',
- 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
- '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
- 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
- '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
- '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
- className
- )}
- {...restProps}
->
- {@render children?.()}
-</button>
+++ /dev/null
-<script lang="ts">
- import { Separator } from '$lib/components/ui/separator/index.js';
- import { cn } from '$lib/components/ui/utils.js';
- import type { ComponentProps } from 'svelte';
-
- let {
- ref = $bindable(null),
- class: className,
- ...restProps
- }: ComponentProps<typeof Separator> = $props();
-</script>
-
-<Separator
- bind:ref
- data-slot="sidebar-separator"
- data-sidebar="separator"
- class={cn('bg-sidebar-border', className)}
- {...restProps}
-/>
+++ /dev/null
-<script lang="ts">
- import { Button } from '$lib/components/ui/button/index.js';
- import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
- import type { ComponentProps } from 'svelte';
- import { useSidebar } from './context.svelte.js';
- import { PanelLeftClose } from '@lucide/svelte';
-
- let {
- ref = $bindable(null),
- class: className,
- onclick,
- ...restProps
- }: ComponentProps<typeof Button> & {
- onclick?: (e: MouseEvent) => void;
- } = $props();
-
- const sidebar = useSidebar();
-</script>
-
-<Button
- data-sidebar="trigger"
- data-slot="sidebar-trigger"
- variant="ghost"
- size="icon-lg"
- class="rounded-full backdrop-blur-lg {className} {sidebar.open
- ? 'top-1.5'
- : 'top-0'} md:left-[calc(var(--sidebar-width)-3.25rem)] {sidebar.isResizing
- ? '!duration-0'
- : ''}"
- type="button"
- onclick={(e) => {
- onclick?.(e);
- sidebar.toggle();
- }}
- {...restProps}
->
- {#if sidebar.open}
- <PanelLeftClose />
- {:else}
- <PanelLeftIcon />
- {/if}
- <span class="sr-only">Toggle Sidebar</span>
-</Button>
+++ /dev/null
-<script lang="ts">
- import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
- import type { HTMLAttributes } from 'svelte/elements';
- import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH } from './constants.js';
- import { useSidebar } from './context.svelte.js';
- import { remToPx } from '$lib/utils';
-
- let {
- ref = $bindable(null),
- side = 'left',
- variant = 'sidebar',
- collapsible = 'offcanvas',
- class: className,
- children,
- ...restProps
- }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
- side?: 'left' | 'right';
- variant?: 'sidebar' | 'floating' | 'inset';
- collapsible?: 'offcanvas' | 'icon' | 'none';
- } = $props();
-
- const sidebar = useSidebar();
-
- function handleResizePointerDown(e: PointerEvent) {
- if (sidebar.isMobile) return;
- e.preventDefault();
-
- const target = e.currentTarget as HTMLElement;
- target.setPointerCapture(e.pointerId);
-
- const minPx = remToPx(SIDEBAR_MIN_WIDTH);
- const maxPx = remToPx(SIDEBAR_MAX_WIDTH);
-
- sidebar.isResizing = true;
-
- function onPointerMove(ev: PointerEvent) {
- const newWidth = side === 'left' ? ev.clientX : window.innerWidth - ev.clientX;
- const clamped = Math.min(maxPx, Math.max(minPx, newWidth));
- sidebar.sidebarWidth = `${clamped}px`;
- }
-
- function onPointerUp() {
- sidebar.isResizing = false;
- target.removeEventListener('pointermove', onPointerMove);
- target.removeEventListener('pointerup', onPointerUp);
- }
-
- target.addEventListener('pointermove', onPointerMove);
- target.addEventListener('pointerup', onPointerUp);
- }
-</script>
-
-{#if collapsible === 'none'}
- <div
- class={cn(
- 'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
- className
- )}
- bind:this={ref}
- {...restProps}
- >
- {@render children?.()}
- </div>
-{:else}
- <div
- bind:this={ref}
- class="group peer block text-sidebar-foreground"
- data-state={sidebar.state}
- data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
- data-variant={variant}
- data-side={side}
- data-slot="sidebar"
- >
- <!-- This is what handles the sidebar gap on desktop -->
- <div
- data-slot="sidebar-gap"
- class={cn(
- 'relative bg-transparent transition-[width] duration-200 ease-linear',
- sidebar.isResizing && '!duration-0',
- 'w-0',
- variant === 'floating'
- ? 'md:w-[calc(var(--sidebar-width)+0.75rem)]'
- : 'md:w-(--sidebar-width)',
- 'md:group-data-[collapsible=offcanvas]:w-0',
- 'group-data-[side=right]:rotate-180',
- variant === 'floating' || variant === 'inset'
- ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
- : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
- )}
- ></div>
-
- <div
- data-slot="sidebar-container"
- class={cn(
- 'fixed inset-y-0 z-[900] flex w-[calc(100dvw-1.5rem)] duration-200 ease-linear md:z-0 md:w-(--sidebar-width)',
- 'group-data-[collapsible=offcanvas]:pointer-events-none md:group-data-[collapsible=offcanvas]:pointer-events-auto',
- sidebar.isResizing && '!duration-0',
- variant === 'floating'
- ? [
- 'transition-[left,right,width,opacity]',
- side === 'left'
- ? 'left-3 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-0.775)] group-data-[collapsible=offcanvas]:opacity-0'
- : 'right-3 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-0.775)] group-data-[collapsible=offcanvas]:opacity-0',
- 'my-3 overflow-hidden rounded-3xl border border-sidebar-border shadow-md'
- ]
- : [
- 'h-svh transition-[left,right,width]',
- side === 'left'
- ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
- : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]'
- ],
- // Adjust the padding for inset variant.
- variant === 'inset'
- ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
- : variant === 'floating'
- ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
- : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
- className
- )}
- style={variant === 'floating' ? 'height: calc(100dvh - 1.5rem);' : undefined}
- {...restProps}
- >
- <div
- data-sidebar="sidebar"
- data-slot="sidebar-inner"
- class="flex h-full w-full flex-col bg-sidebar"
- >
- {@render children?.()}
- </div>
- <!-- Resize handle -->
- {#if side === 'left'}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- data-slot="sidebar-resize-handle"
- class="absolute inset-y-0 right-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
- class:bg-sidebar-border={sidebar.isResizing}
- onpointerdown={handleResizePointerDown}
- ></div>
- {:else}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- data-slot="sidebar-resize-handle"
- class="absolute inset-y-0 left-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
- class:bg-sidebar-border={sidebar.isResizing}
- onpointerdown={handleResizePointerDown}
- ></div>
- {/if}
- </div>
- </div>
-{/if}
export const MEDIA_QUERIES = {
PREFERS_DARK: '(prefers-color-scheme: dark)',
- PREFERS_LIGHT: '(prefers-color-scheme: light)'
+ PREFERS_LIGHT: '(prefers-color-scheme: light)',
+ DISPLAY_MODE_STANDALONE: '(display-mode: standalone)'
} as const;
export const THEME_COLORS = {
SVG_DARK: 'favicon-dark.svg'
} as const;
+// Substituted for `currentColor` in src/lib/assets/logo.svg when generating
+// the light/dark static sources consumed by the PWA asset generator.
+export const FAVICON_COLORS = {
+ LIGHT: '#111111',
+ DARK: '#fafafa'
+} as const;
+
export const FAVICON_SELECTORS = {
ICO_48X48: 'link[rel="icon"][sizes="48x48"]',
SVG_ANY: 'link[rel="icon"][type="image/svg+xml"]'
] as const;
// PWA assets generator configuration โ used by pwa-assets.config.ts
+// FAVICON_PADDING: fraction (0..1) of the icon reserved as equal margin on
+// each side. Applied to icon PNG/ICO outputs by @vite-pwa/assets-generator and
+// post-processed into the static favicon.svg so the in-app logo (which reads
+// src/lib/assets/logo.svg directly) is unaffected.
export const PWA_ASSET_GENERATOR = {
LINK_PRESET: '2023',
+ FAVICON_PADDING: 0.04,
SPLASH_PADDING: 0.75,
FIT_MODE: 'contain',
ADD_MEDIA_SCREEN: true,
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
/** Settings base โ for dynamic settings URLs use RouterService. */
- SETTINGS: '#/settings'
+ SETTINGS: '#/settings',
+ /** Search โ mobile-only full-page conversation search. */
+ SEARCH: '#/search'
} as const;
-import { Settings, Search, SquarePen } from '@lucide/svelte';
+import { Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
import { ROUTES } from './routes';
route?: string;
activeRouteId?: string;
activeRoutePrefix?: string;
+ activeUrlIncludes?: string;
keys?: string[];
}
{
icon: Settings,
tooltip: 'Settings',
- route: ROUTES.SETTINGS,
- activeRoutePrefix: '/settings'
+ route: `${ROUTES.SETTINGS}/general`,
+ activeUrlIncludes: '#/settings'
}
];
*/
resetScrollState(): void {
this._userScrolledUp = false;
- this._autoScrollEnabled = true;
+ this._autoScrollEnabled = !this._disabled;
+ if (this._container) {
+ this._lastScrollTop = this._container.scrollTop;
+ }
}
/**
--- /dev/null
+/**
+ * Active model resolution and capability detection for the ChatScreen.
+ *
+ * Picks the model that should be used for the current view
+ * (router: user-selected or conversation fallback; non-router: first
+ * available option), and reactively tracks which modalities (vision /
+ * audio / video) it supports โ fetching model props from the server on
+ * demand if they aren't cached yet.
+ */
+
+import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+import { isRouterMode } from '$lib/stores/server.svelte';
+import { chatStore } from '$lib/stores/chat.svelte';
+import { activeMessages } from '$lib/stores/conversations.svelte';
+
+export function useChatScreenActiveModel() {
+ const isRouter = $derived(isRouterMode());
+ const conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ const activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ let modelPropsVersion = $state(0);
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.getModelProps(activeModelId);
+ if (!cached) {
+ modelsStore.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ const hasAudioModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+ return modelsStore.modelSupportsAudio(activeModelId);
+ }
+ return false;
+ });
+
+ const hasVideoModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+ return modelsStore.modelSupportsVideo(activeModelId);
+ }
+ return false;
+ });
+
+ const hasVisionModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+ return modelsStore.modelSupportsVision(activeModelId);
+ }
+ return false;
+ });
+
+ return {
+ get isRouter() {
+ return isRouter;
+ },
+ get conversationModel() {
+ return conversationModel;
+ },
+ get activeModelId() {
+ return activeModelId;
+ },
+ get hasAudioModality() {
+ return hasAudioModality;
+ },
+ get hasVideoModality() {
+ return hasVideoModality;
+ },
+ get hasVisionModality() {
+ return hasVisionModality;
+ }
+ };
+}
--- /dev/null
+/**
+ * Drag-and-drop state machine for the ChatScreen.
+ *
+ * Tracks pointer enter/leave nesting so the overlay stays visible while the
+ * cursor traverses child elements, then routes the dropped files either to
+ * the active message-edit handler (if a message is being edited) or to the
+ * caller's onDrop callback.
+ */
+
+import { getAddFilesHandler, isEditing } from '$lib/stores/chat.svelte';
+
+interface UseChatScreenDragAndDropOptions {
+ /** Called when the user drops files and no message is being edited. */
+ onDrop: (files: File[]) => void;
+}
+
+export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOptions) {
+ let dragCounter = $state(0);
+ let isDragOver = $state(false);
+
+ function handleDragEnter(event: DragEvent) {
+ event.preventDefault();
+ dragCounter++;
+ if (event.dataTransfer?.types.includes('Files')) {
+ isDragOver = true;
+ }
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ event.preventDefault();
+ dragCounter--;
+ if (dragCounter === 0) {
+ isDragOver = false;
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ }
+
+ async function handleDrop(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = false;
+ dragCounter = 0;
+
+ if (!event.dataTransfer?.files) return;
+
+ const files = Array.from(event.dataTransfer.files);
+
+ if (isEditing()) {
+ const handler = getAddFilesHandler();
+ if (handler) {
+ handler(files);
+ return;
+ }
+ }
+
+ options.onDrop(files);
+ }
+
+ return {
+ get isDragOver() {
+ return isDragOver;
+ },
+ dragHandlers: {
+ dragenter: handleDragEnter,
+ dragleave: handleDragLeave,
+ dragover: handleDragOver,
+ drop: handleDrop
+ }
+ };
+}
--- /dev/null
+/**
+ * File upload lifecycle for the ChatScreen form.
+ *
+ * Owns the queue of processed `ChatUploadedFile`, the rejection-by-capability
+ * dialog state, and the dual-layer validation pipeline (general format +
+ * model modality). The caller provides the active model's capabilities and ID
+ * as reactive getters so validation tracks the model in real time.
+ */
+
+import { processFilesToChatUploaded } from '$lib/utils/browser-only';
+import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
+
+interface UseChatScreenFileUploadOptions {
+ capabilities: () => { hasVision: boolean; hasAudio: boolean; hasVideo: boolean };
+ activeModelId: () => string | null | undefined;
+}
+
+export interface FileErrorData {
+ generallyUnsupported: File[];
+ modalityUnsupported: File[];
+ modalityReasons: Record<string, string>;
+ supportedTypes: string[];
+}
+
+export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions) {
+ let uploadedFiles = $state<ChatUploadedFile[]>([]);
+ let showFileErrorDialog = $state(false);
+ let fileErrorData = $state<FileErrorData>({
+ generallyUnsupported: [],
+ modalityUnsupported: [],
+ modalityReasons: {},
+ supportedTypes: []
+ });
+
+ async function processFiles(files: File[]) {
+ const generallySupported: File[] = [];
+ const generallyUnsupported: File[] = [];
+
+ for (const file of files) {
+ if (isFileTypeSupported(file.name, file.type)) {
+ generallySupported.push(file);
+ } else {
+ generallyUnsupported.push(file);
+ }
+ }
+
+ const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
+ generallySupported,
+ options.capabilities()
+ );
+
+ const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
+
+ if (allUnsupportedFiles.length > 0) {
+ const supportedTypes: string[] = ['text files', 'PDFs'];
+ const caps = options.capabilities();
+ if (caps.hasVision) supportedTypes.push('images');
+ if (caps.hasAudio) supportedTypes.push('audio files');
+ if (caps.hasVideo) supportedTypes.push('video files');
+
+ fileErrorData = {
+ generallyUnsupported,
+ modalityUnsupported: unsupportedFiles,
+ modalityReasons,
+ supportedTypes
+ };
+ showFileErrorDialog = true;
+ }
+
+ if (supportedFiles.length > 0) {
+ const processed = await processFilesToChatUploaded(
+ supportedFiles,
+ options.activeModelId() ?? undefined
+ );
+ uploadedFiles = [...uploadedFiles, ...processed];
+ }
+ }
+
+ function handleFileUpload(files: File[]) {
+ return processFiles(files);
+ }
+
+ function handleFileRemove(fileId: string) {
+ uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
+ }
+
+ return {
+ get uploadedFiles() {
+ return uploadedFiles;
+ },
+ set uploadedFiles(value) {
+ uploadedFiles = value;
+ },
+ get showFileErrorDialog() {
+ return showFileErrorDialog;
+ },
+ set showFileErrorDialog(value) {
+ showFileErrorDialog = value;
+ },
+ fileErrorData,
+ handleFileUpload,
+ handleFileRemove
+ };
+}
--- /dev/null
+/**
+ * Scroll container binding and navigation guard for the ChatScreen.
+ *
+ * Binds the `AutoScrollController` to `document.documentElement`, exposes
+ * the container for programmatic scrolling, and flags an `isNavigating`
+ * window during route changes so the controller can reset without its
+ * scroll handler seeing spurious events from layout shifts.
+ */
+
+import { afterNavigate, beforeNavigate } from '$app/navigation';
+import type { AutoScrollController } from './use-auto-scroll.svelte';
+
+export function useChatScreenScroll(autoScroll: AutoScrollController) {
+ let chatScrollContainer: HTMLElement | undefined = $state();
+ let isNavigating = $state(false);
+
+ function handleScroll(event: UIEvent) {
+ // Ignore scroll events caused by navigation layout changes or by our own
+ // programmatic scrolls so they don't accidentally disable auto-scroll.
+ if (isNavigating || !event.isTrusted) return;
+ autoScroll.handleScroll();
+ }
+
+ beforeNavigate(() => {
+ isNavigating = true;
+ autoScroll.resetScrollState();
+ });
+
+ afterNavigate(() => {
+ setTimeout(() => {
+ isNavigating = false;
+ autoScroll.resetScrollState();
+ }, 10);
+ });
+
+ $effect(() => {
+ chatScrollContainer = document.documentElement;
+ autoScroll.setContainer(chatScrollContainer);
+ });
+
+ return {
+ get chatScrollContainer() {
+ return chatScrollContainer;
+ },
+ handleScroll
+ };
+}
'[data-slot="chat-form"] textarea'
);
- textarea?.focus();
+ textarea?.focus({ preventScroll: true });
});
}
--- /dev/null
+import { browser } from '$app/environment';
+import { MEDIA_QUERIES } from '$lib/constants';
+
+/**
+ * iOS UA token detection.
+ *
+ * iPadOS 13+ ships a desktop macOS UA, so 'iPad' is no longer present in it;
+ * a Macintosh UA combined with touch support is treated as an iPad instead.
+ * Third-party iOS browsers (Chrome, Firefox, Edge) and in-app WKWebViews all
+ * run on WKWebView and emit their own tokens (CriOS/FxiOS/EdgiOS/GSA) instead
+ * of the trailing 'Safari/' the Safari app keeps.
+ */
+const UA_PATTERNS = {
+ IOS_PHONE: /iPhone|iPod/,
+ MACINTOSH: /Macintosh/,
+ SAFARI: /Safari/,
+ WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/
+} as const;
+
+interface DeviceContext {
+ /** Any iOS/iPadOS device, regardless of which app or browser embeds the page. */
+ isIOSDevice: boolean;
+ /** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
+ isIOSSafari: boolean;
+ /** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
+ * third-party iOS browsers (all of which share the WKWebView engine). */
+ isWKWebView: boolean;
+ /** PWA standalone mode: the page was launched from the home screen icon. */
+ isStandalone: boolean;
+}
+
+const SERVER_DEFAULT: DeviceContext = {
+ isIOSDevice: false,
+ isIOSSafari: false,
+ isWKWebView: false,
+ isStandalone: false
+};
+
+function detect(): DeviceContext {
+ if (!browser) return SERVER_DEFAULT;
+
+ const ua = navigator.userAgent;
+ const isTouch = navigator.maxTouchPoints > 0;
+
+ const isIOSDevice = UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
+
+ // Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
+ // token instead. WKWebView typically omits 'Safari/' entirely.
+ const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
+ const isIOSSafari = isIOSDevice && hasSafariToken;
+ const isWKWebView = isIOSDevice && !hasSafariToken;
+
+ // navigator.standalone is the legacy iOS-only flag (deprecated but still
+ // present); display-mode: standalone is the modern standard (Safari 16.4+).
+ const isStandalone =
+ window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
+ (navigator as Navigator & { standalone?: boolean }).standalone === true;
+
+ return { isIOSDevice, isIOSSafari, isWKWebView, isStandalone };
+}
+
+export const device = $state<DeviceContext>(detect());
+
+if (browser) {
+ // isStandalone can change at runtime (e.g. user installs the PWA while the
+ // tab is open); the UA-derived flags are static for the session.
+ const mql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
+
+ mql.addEventListener('change', (e) => {
+ device.isStandalone = e.matches;
+ });
+}
}
// Try loading a favorite model
- const favorite = this.favoriteModelIds.values().next()?.value
+ const favorite = this.favoriteModelIds.values().next()?.value;
if (favorite) {
await this.selectModelById(favorite);
return;
import { page } from '$app/state';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
- import { fade } from 'svelte/transition';
- import {
- DesktopIconStrip,
- DialogConversationTitleUpdate,
- SidebarNavigation
- } from '$lib/components/app';
+ import { SidebarNavigation, DialogConversationTitleUpdate } from '$lib/components/app';
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
import { conversationsStore } from '$lib/stores/conversations.svelte';
- import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { usePwa } from '$lib/hooks/use-pwa.svelte';
- import { useSettingsNavigation } from '$lib/hooks/use-settings-navigation.svelte';
import { conversations } from '$lib/stores/conversations.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import { theme } from '$lib/stores/theme.svelte';
let { children } = $props();
let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
let isDesktop = $derived(!isMobile.current);
- let sidebarOpen = $state(false);
- let mounted = $state(false);
let innerHeight = $state<number | undefined>();
let innerWidth = $state(browser ? window.innerWidth : 0);
let titleUpdateNewTitle = $state('');
let titleUpdateResolve: ((value: boolean) => void) | null = null;
- const panelNav = useSettingsNavigation();
// Keep the hook object intact: destructuring needRefreshByStorage reads the getter once and freezes it
const pwa = usePwa();
const { needRefresh, updateServiceWorker } = pwa;
onMount(() => {
updateFavicon();
- mounted = true;
});
$effect(() => {
$effect(() => {
if (alwaysShowSidebarOnDesktop && isDesktop) {
- sidebarOpen = true;
-
return;
}
});
<PwaMetaTags />
</svelte:head>
-<!-- PWA update prompt + version -->
-<div class="fixed right-4 bottom-4 z-[9999] flex flex-col items-end gap-1">
- {#if showBuildVersion && buildInfoStore.value}
- <span class="text-[10px] tabular-nums text-muted-foreground">{buildInfoStore.value}</span>
- {/if}
- <PwaRefreshAlert
- needRefresh={$needRefresh || pwa.needRefreshByStorage}
- forceReload={pwa.needRefreshByStorage}
- {updateServiceWorker}
- />
-</div>
+<svelte:window onkeydown={handleKeydown} bind:innerHeight bind:innerWidth />
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
+ <div class="flex flex-col md:flex-row">
+ <SidebarNavigation
+ onSearchClick={() => {
+ if (isMobile.current) {
+ goto(ROUTES.SEARCH);
+ } else if (chatSidebar?.activateSearchMode) {
+ chatSidebar.activateSearchMode();
+ }
+ }}
+ />
+
+ <div class="flex-1">
+ {@render children?.()}
+ </div>
+ </div>
+
<ModeWatcher />
<Toaster richColors />
onConfirm={handleTitleUpdateConfirm}
onCancel={handleTitleUpdateCancel}
/>
-
- <Sidebar.Provider bind:open={sidebarOpen}>
- <div class="flex h-full w-full grow">
- <Sidebar.Root variant="floating" class="h-full"
- ><SidebarNavigation bind:this={chatSidebar} /></Sidebar.Root
- >
-
- {#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(panelNav.isSettingsRoute && !isDesktop)}
- {#if mounted}
- <div in:fade={{ duration: 200 }}>
- <Sidebar.Trigger
- class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
- ? 'left-[calc(var(--sidebar-width)+0.75rem)] max-md:hidden'
- : 'left-0!'}"
- style="translate: 1rem 1rem;"
- />
- </div>
- {/if}
- {/if}
-
- {#if isDesktop && !alwaysShowSidebarOnDesktop}
- <DesktopIconStrip
- {sidebarOpen}
- onSearchClick={() => {
- if (chatSidebar?.activateSearchMode) {
- chatSidebar.activateSearchMode();
- }
-
- sidebarOpen = true;
- }}
- />
- {/if}
-
- <Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
- {@render children?.()}
- </Sidebar.Inset>
- </div>
- </Sidebar.Provider>
</Tooltip.Provider>
-<svelte:window onkeydown={handleKeydown} bind:innerHeight bind:innerWidth />
+<!-- PWA update prompt + version -->
+<div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1">
+ {#if showBuildVersion && buildInfoStore.value}
+ <span class="text-[10px] tabular-nums text-muted-foreground">{buildInfoStore.value}</span>
+ {/if}
+
+ <PwaRefreshAlert
+ needRefresh={$needRefresh || pwa.needRefreshByStorage}
+ forceReload={pwa.needRefreshByStorage}
+ {updateServiceWorker}
+ />
+</div>
--- /dev/null
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { page } from '$app/state';
+ import { browser } from '$app/environment';
+
+ import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
+ import { ROUTES } from '$lib/constants/routes';
+ import { RouterService } from '$lib/services/router.service';
+ import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { isMobile } from '$lib/stores/viewport.svelte';
+
+ let searchQuery = $state('');
+ let searchInputRef = $state<HTMLInputElement | null>(null);
+
+ let currentChatId = $derived(page.params.id);
+
+ let filteredConversations = $derived.by(() => {
+ const query = searchQuery.trim().toLowerCase();
+ if (query.length === 0) return [];
+ return conversations().filter((c) => c.name.toLowerCase().includes(query));
+ });
+
+ // Search page is intended for mobile; on desktop the sidebar already exposes
+ // in-place search, so bounce back to a chat.
+ $effect(() => {
+ if (browser && !isMobile.current) {
+ goto(ROUTES.NEW_CHAT, { replaceState: true });
+ }
+ });
+
+ async function selectConversation(id: string) {
+ await goto(RouterService.chat(id));
+ }
+
+ async function handleEditConversation(id: string) {
+ const conversation = conversations().find((c) => c.id === id);
+ if (!conversation) return;
+
+ const newName = window.prompt('Rename conversation', conversation.name);
+ if (newName && newName.trim()) {
+ await conversationsStore.updateConversationName(id, newName.trim());
+ }
+ }
+
+ async function handleDeleteConversation(id: string) {
+ const conversation = conversations().find((c) => c.id === id);
+ if (!conversation) return;
+
+ const confirmed = window.confirm(
+ `Delete "${conversation.name}"? This action cannot be undone.`
+ );
+ if (!confirmed) return;
+
+ await conversationsStore.deleteConversation(id, { deleteWithForks: false });
+ }
+
+ function handleStopGeneration(id: string) {
+ chatStore.stopGenerationForChat(id);
+ }
+
+ function handleBack() {
+ if (history.length > 1) {
+ history.back();
+ } else {
+ goto(ROUTES.NEW_CHAT);
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>Search ยท llama.cpp</title>
+</svelte:head>
+
+<div class="fixed top-0 z-10 left-0 right-0 p-2">
+ <SearchInput
+ autofocus
+ bind:value={searchQuery}
+ bind:ref={searchInputRef}
+ onClose={handleBack}
+ placeholder="Search conversations..."
+ />
+</div>
+
+<div class="p-2 pt-16">
+ <SidebarNavigationSearchResults
+ {searchQuery}
+ {filteredConversations}
+ {currentChatId}
+ onSelect={selectConversation}
+ onEdit={handleEditConversation}
+ onDelete={handleDeleteConversation}
+ onStop={handleStopGeneration}
+ />
+</div>
}
</script>
-<div class="relative h-full">
- <div class="fixed top-4.5 right-4 z-50 md:hidden">
- <ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
- </div>
+<div class="fixed top-4.5 right-4 z-50 md:hidden">
+ <ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
+</div>
- <div class="min-h-full">
- {@render children?.()}
- </div>
+<div class="min-h-full">
+ {@render children?.()}
</div>
<script lang="ts">
import { SettingsChat } from '$lib/components/app/settings';
import { page } from '$app/state';
- import { replaceState } from '$app/navigation';
+ import { afterNavigate, replaceState } from '$app/navigation';
import { RouterService } from '$lib/services';
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
- import { onMount } from 'svelte';
- onMount(() => {
+ afterNavigate(() => {
if (!page.params.section) {
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
}
+++ /dev/null
-<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_29_291)">
-<path d="M244.95 8C215.233 8 187.774 23.8591 172.923 49.5999L95.6009 183.625C60.2162 244.959 104.481 321.6 175.29 321.6H208L316.977 132.708C348.959 77.2719 308.95 8 244.95 8ZM208 321.6H351.947C415.982 321.6 456.013 390.91 424.013 446.377C409.155 472.132 381.681 488 351.947 488H271.29C200.481 488 156.216 411.359 191.601 350.026L208 321.6Z" fill="#FAFAFA"/>
-<path d="M208 321.6H16L106.462 164.8L208 321.6Z" fill="#FAFAFA"/>
-<path d="M388.923 8L208 321.6L253.6 8H388.923Z" fill="#FAFAFA"/>
-<path d="M304 488H112L202.462 331.2L304 488Z" fill="#FAFAFA"/>
-<path d="M496 321.6H208L419.399 454.4L496 321.6Z" fill="#FAFAFA"/>
-</g>
-<defs>
-<clipPath id="clip0_29_291">
-<rect width="512" height="512" fill="white"/>
-</clipPath>
-</defs>
-</svg>
+++ /dev/null
-<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0_29_291)">
-<path d="M244.95 8C215.233 8 187.774 23.8591 172.923 49.5999L95.6009 183.625C60.2162 244.959 104.481 321.6 175.29 321.6H208L316.977 132.708C348.959 77.2719 308.95 8 244.95 8ZM208 321.6H351.947C415.982 321.6 456.013 390.91 424.013 446.377C409.155 472.132 381.681 488 351.947 488H271.29C200.481 488 156.216 411.359 191.601 350.026L208 321.6Z" fill="#111111"/>
-<path d="M208 321.6H16L106.462 164.8L208 321.6Z" fill="#111111"/>
-<path d="M388.923 8L208 321.6L253.6 8H388.923Z" fill="#111111"/>
-<path d="M304 488H112L202.462 331.2L304 488Z" fill="#111111"/>
-<path d="M496 321.6H208L419.399 454.4L496 321.6Z" fill="#111111"/>
-</g>
-<defs>
-<clipPath id="clip0_29_291">
-<rect width="512" height="512" fill="white"/>
-</clipPath>
-</defs>
-</svg>
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
- import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import Page from '../../../src/routes/(chat)/+page.svelte';
-
- let sidebarOpen = $state(false);
</script>
<!--
This mirrors the providers from +layout.svelte.
-->
<Tooltip.Provider>
- <Sidebar.Provider bind:open={sidebarOpen}>
- <Page />
- </Sidebar.Provider>
+ <Page />
</Tooltip.Provider>
</script>
<script lang="ts">
- import * as Sidebar from '$lib/components/ui/sidebar/index.js';
-
- let sidebarOpen = $state(true);
-
// Mock conversations for the sidebar
const mockConversations: DatabaseConversation[] = [
{
);
}}
>
- <Sidebar.Provider bind:open={sidebarOpen}>
- <div class="flex-column h-full h-screen w-72 bg-background">
- <SidebarNavigation />
- </div>
- </Sidebar.Provider>
+ <div class="flex-column h-screen w-72 bg-background">
+ <SidebarNavigation />
+ </div>
</Story>
<Story
}, 0)
);
+ // Expand sidebar first, then click Search in the expanded button list
+ const logoTrigger = screen.getByRole('button', { name: /expand navigation/i });
+ await userEvent.click(logoTrigger);
const searchTrigger = screen.getByText('Search');
userEvent.click(searchTrigger);
}}
>
- <Sidebar.Provider bind:open={sidebarOpen}>
- <div class="flex-column h-full h-screen w-72 bg-background">
- <SidebarNavigation />
- </div>
- </Sidebar.Provider>
+ <div class="flex-column h-screen w-72 bg-background">
+ <SidebarNavigation />
+ </div>
</Story>
<Story
conversationsStore.conversations = [];
}}
>
- <Sidebar.Provider bind:open={sidebarOpen}>
- <div class="flex-column h-full h-screen w-72 bg-background">
- <SidebarNavigation />
- </div>
- </Sidebar.Provider>
+ <div class="flex-column h-screen w-72 bg-background">
+ <SidebarNavigation />
+ </div>
</Story>
--- /dev/null
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ colorizeFaviconSvg,
+ padFaviconSvg,
+ writeThemeFavicons
+} from '../../scripts/favicon-colorize';
+
+const SOURCE_SVG = [
+ '<svg xmlns="http://www.w3.org/2000/svg">',
+ ' <path d="M0 0" fill="currentColor"/>',
+ ' <path d="M1 1" fill="#ff00aa"/>',
+ ' <circle fill="currentColor"/>',
+ '</svg>'
+].join('\n');
+
+describe('colorizeFaviconSvg', () => {
+ it('substitutes every currentColor occurrence for the light variant', () => {
+ const { light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+ expect(light.match(/currentColor/g)).toBeNull();
+ expect(light).toContain('fill="#111111"');
+ expect(light).toContain('<circle fill="#111111"/>');
+ });
+
+ it('substitutes every currentColor occurrence for the dark variant', () => {
+ const { dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+ expect(dark.match(/currentColor/g)).toBeNull();
+ expect(dark).toContain('fill="#fafafa"');
+ expect(dark).toContain('<circle fill="#fafafa"/>');
+ });
+
+ it('leaves non-currentColor colors untouched in both variants', () => {
+ const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+ expect(light).toContain('fill="#ff00aa"');
+ expect(dark).toContain('fill="#ff00aa"');
+ });
+
+ it('does not alter any other part of the SVG', () => {
+ const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
+ const stripColors = (s: string) =>
+ s.replaceAll('#111111', '').replaceAll('#fafafa', '').replaceAll('currentColor', '');
+ const expected = stripColors(SOURCE_SVG);
+ expect(stripColors(light)).toBe(expected);
+ expect(stripColors(dark)).toBe(expected);
+ });
+
+ it('returns the same SVG for light and dark when called with the same color', () => {
+ const result = colorizeFaviconSvg(SOURCE_SVG, '#abcdef', '#abcdef');
+ expect(result.light).toBe(result.dark);
+ });
+
+ it('returns the source unchanged when given a color that does not appear (no currentColor in source)', () => {
+ const plain = '<svg><path fill="#000"/></svg>';
+ const { light, dark } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
+ expect(light).toBe(plain);
+ expect(dark).toBe(plain);
+ });
+});
+
+describe('padFaviconSvg', () => {
+ const SIZED_SVG =
+ '<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">' +
+ '<path d="M244.95 8L388.923 8Z" fill="currentColor"/>' +
+ '</svg>';
+
+ it('wraps inner content in a translate-then-scale group that matches padding', () => {
+ const padded = padFaviconSvg(SIZED_SVG, 0.05);
+ // scale = 1 - 0.05 = 0.95
+ // translate = (0.05 * 512) / 2 = 12.8 on each axis
+ expect(padded).toContain('transform="translate(12.8 12.8) scale(0.95)"');
+ expect(padded).toContain('<g transform="translate(12.8 12.8) scale(0.95)">');
+ expect(padded).toContain('<path d="M244.95 8L388.923 8Z" fill="currentColor"/>');
+ expect(padded.endsWith('</g></svg>')).toBe(true);
+ });
+
+ it('preserves the outer <svg> tag attributes', () => {
+ const padded = padFaviconSvg(SIZED_SVG, 0.1);
+ expect(padded.startsWith('<svg width="512" height="512" viewBox="0 0 512 512"')).toBe(true);
+ });
+
+ it('returns the input unchanged for zero or negative padding', () => {
+ expect(padFaviconSvg(SIZED_SVG, 0)).toBe(SIZED_SVG);
+ expect(padFaviconSvg(SIZED_SVG, -0.1)).toBe(SIZED_SVG);
+ });
+
+ it('returns the input unchanged when padding would fully collapse the icon (>= 1)', () => {
+ expect(padFaviconSvg(SIZED_SVG, 1)).toBe(SIZED_SVG);
+ expect(padFaviconSvg(SIZED_SVG, 1.5)).toBe(SIZED_SVG);
+ });
+
+ it('returns the input unchanged when no viewBox is present', () => {
+ const noViewBox = '<svg width="32" height="32"><path d="M0 0Z"/></svg>';
+ expect(padFaviconSvg(noViewBox, 0.1)).toBe(noViewBox);
+ });
+
+ it('returns the input unchanged when viewBox values are not finite numbers', () => {
+ const bad = '<svg viewBox="auto auto 0 0"><path/></svg>';
+ expect(padFaviconSvg(bad, 0.1)).toBe(bad);
+ });
+
+ it('tolerates a non-square viewBox', () => {
+ const wide = '<svg viewBox="0 0 100 50"><rect/></svg>';
+ const padded = padFaviconSvg(wide, 0.1);
+ // scale 0.9, translate (5, 2.5)
+ expect(padded).toContain('transform="translate(5 2.5) scale(0.9)"');
+ });
+});
+
+describe('writeThemeFavicons', () => {
+ const LOGO =
+ '<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">' +
+ '<path d="M244.95 8L388.923 8Z" fill="currentColor"/>' +
+ '</svg>';
+
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), 'favicon-'));
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ function setupSource() {
+ const sourcePath = join(tmpDir, 'logo.svg');
+ writeFileSync(sourcePath, LOGO);
+ return {
+ sourcePath,
+ lightPath: join(tmpDir, 'favicon.svg'),
+ darkPath: join(tmpDir, 'favicon-dark.svg')
+ };
+ }
+
+ it('writes colorized, un-padded favicons without modifying the source', () => {
+ const { sourcePath, lightPath, darkPath } = setupSource();
+ const before = readFileSync(sourcePath, 'utf-8');
+
+ writeThemeFavicons('#abcdef', '#012345', {
+ sourcePath,
+ lightOutPath: lightPath,
+ darkOutPath: darkPath
+ });
+
+ const lightOut = readFileSync(lightPath, 'utf-8');
+ const darkOut = readFileSync(darkPath, 'utf-8');
+
+ // currentColor swapped to the requested palette in both files
+ expect(lightOut).toContain('fill="#abcdef"');
+ expect(lightOut).not.toContain('currentColor');
+ expect(darkOut).toContain('fill="#012345"');
+ expect(darkOut).not.toContain('currentColor');
+
+ // default padding (0) keeps the wrapper off the output
+ expect(lightOut).not.toContain('<g ');
+ expect(darkOut).not.toContain('<g ');
+
+ // source file is unchanged after the call
+ expect(readFileSync(sourcePath, 'utf-8')).toBe(before);
+ });
+
+ it('writes colorized favicons wrapped in a padding <g transform>...</g>', () => {
+ const { sourcePath, lightPath, darkPath } = setupSource();
+ // mirror the production wiring: PWA_ASSET_GENERATOR.FAVICON_PADDING
+ const padding = 0.04;
+ // scale = 1 - 0.04 = 0.96; translate = (0.04 * 512) / 2 = 10.24
+ const expectedTransform = 'transform="translate(10.24 10.24) scale(0.96)"';
+
+ writeThemeFavicons('#111111', '#fafafa', {
+ sourcePath,
+ lightOutPath: lightPath,
+ darkOutPath: darkPath,
+ padding
+ });
+
+ const lightOut = readFileSync(lightPath, 'utf-8');
+ const darkOut = readFileSync(darkPath, 'utf-8');
+
+ // both files get the same padding wrapper derived from the viewBox
+ expect(lightOut).toContain(`<g ${expectedTransform}>`);
+ expect(lightOut.endsWith('</g></svg>')).toBe(true);
+ expect(darkOut).toContain(`<g ${expectedTransform}>`);
+ expect(darkOut.endsWith('</g></svg>')).toBe(true);
+
+ // colorization still happens inside the wrapped group
+ expect(lightOut).toContain('fill="#111111"');
+ expect(lightOut).not.toContain('currentColor');
+ expect(darkOut).toContain('fill="#fafafa"');
+ expect(darkOut).not.toContain('currentColor');
+ // light palette colour must not leak into dark output
+ expect(darkOut).not.toContain('#111111');
+
+ // source file is not modified by the padding step
+ expect(readFileSync(sourcePath, 'utf-8')).toBe(LOGO);
+ });
+});