]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: New Logo + Navigation cleanup & Mobile UI/UX improvements (#24897)
authorAleksander Grygier <redacted>
Wed, 24 Jun 2026 08:21:33 +0000 (10:21 +0200)
committerGitHub <redacted>
Wed, 24 Jun 2026 08:21:33 +0000 (10:21 +0200)
* chore: `npm audit fix --force`

* feat: Update sidebar toggle to use Logo

* refactor: Clean up favicon SVG

* feat: Refactor logo component and implement theme-aware favicon generation

* feat: Add configurable padding to generated PWA assets

* test: Add unit tests for writeThemeFavicons

* refactor: Componentization

* feat: WIP

* feat: WIP

* feat: WIP

* feat: Mobile UI

* feat: add SEARCH route constant

* feat: create SidebarNavigationSearchResults component

* refactor: use SidebarNavigationSearchResults in conversation list

* feat: enable mobile search navigation in sidebar actions

* feat: add mobile search route and page

* fix: prevent sidebar overflow on mobile viewports

* fix: Mobile sidebar

* feat: Mobile Search WIP

* feat: Mobile WIP

* feat: Add PWA standalone detection and refine mobile UI

* feat: Improve mobile layout, sidebar handling, and chat scrolling

* feat: Improve mobile sidebar visibility and iOS Safari chat spacing

* fix: Disable auto-scroll on mobile

* chore: Linting

* fix: Wrong condition

* feat: Mobile chat scroll

* refactor: WIP

* fix: Desktop initial scroll always working again

* fix: Partial fix for mobile auto-scroll / initial scroll

* fix: Desktop auto-scroll on initial load and during streaming

* fix: Mobile scrolling logic

* refactor: Clean up

* feat: Improve start UI

* feat: Add `delay` to `fadeInView`

* feat: Auto-scroll button

* refactor: Cleanup

* refactor: Extract chat dialogs and alerts into dedicated component

* refactor: Reorganize ChatScreen component structure and initialization

* feat: Improve auto-scroll after sending message

* feat: UI improvements

* fix: Settings link

* feat: UI improvements

* fix: better UI spacing

* fix: Remove unneeded logic

* fix: Chat Processing Info UI rendering

* feat: Improve mobile UI

* feat: UI improvement

* fix: Conditional transition delay for Chat Messages based on route from

* fix: Delay mobile sidebar collapse for smoother transitions

* fix: Mobile scroll down button + sidebar pointer events

* fix: Mobile UI

* fix: Auto scrolling

* fix: Implement dynamic height calculations for chat auto-scroll positioning and UI elements

* fix: Retrieve `autofocus` for Chat Form textarea

* fix: Use proper class

Co-authored-by: Copilot Autofix powered by AI <redacted>
* refactor: extract scroll-to-bottom logic and fix message send flow

* fix: update viewport store usage and remove conflicting autofocus

* feat: add accessibility labels to scroll down button

* fix: correct HTML structure in sidebar empty states

* fix: dynamically toggle processing info visibility

* chore: remove commented exports and fix formatting

* fix

* fix: Mobile Chat Form Add Action Sheet interactions

---------

Co-authored-by: Copilot Autofix powered by AI <redacted>
88 files changed:
tools/ui/.gitignore
tools/ui/package-lock.json
tools/ui/package.json
tools/ui/pwa-assets-dark.config.ts
tools/ui/pwa-assets.config.ts
tools/ui/scripts/favicon-colorize.ts [new file with mode: 0644]
tools/ui/src/app.css
tools/ui/src/lib/actions/fade-in-view.svelte.ts
tools/ui/src/lib/assets/logo.svg [new file with mode: 0644]
tools/ui/src/lib/components/app/actions/ActionIcon.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/components/app/forms/SearchInput.svelte
tools/ui/src/lib/components/app/misc/Logo.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/misc/index.ts
tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte [deleted file]
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearch.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearchResults.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/navigation/index.ts
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
tools/ui/src/lib/components/app/settings/SettingsChatDesktopSidebar.svelte
tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte
tools/ui/src/lib/components/ui/button/button.svelte
tools/ui/src/lib/components/ui/sidebar/constants.ts [deleted file]
tools/ui/src/lib/components/ui/sidebar/context.svelte.ts [deleted file]
tools/ui/src/lib/components/ui/sidebar/index.ts [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-content.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-footer.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-group.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-header.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-input.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-inset.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-menu.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-provider.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-rail.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-separator.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte [deleted file]
tools/ui/src/lib/components/ui/sidebar/sidebar.svelte [deleted file]
tools/ui/src/lib/constants/pwa.ts
tools/ui/src/lib/constants/routes.ts
tools/ui/src/lib/constants/ui.ts
tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
tools/ui/src/lib/hooks/use-chat-screen-active-model.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-chat-screen-file-upload.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-models-selector.svelte.ts
tools/ui/src/lib/stores/device.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/stores/models.svelte.ts
tools/ui/src/routes/+layout.svelte
tools/ui/src/routes/search/+page.svelte [new file with mode: 0644]
tools/ui/src/routes/settings/+layout.svelte
tools/ui/src/routes/settings/[[section]]/+page.svelte
tools/ui/static/favicon-dark.svg [deleted file]
tools/ui/static/favicon.svg [deleted file]
tools/ui/tests/client/components/TestWrapper.svelte
tools/ui/tests/stories/SidebarNavigation.stories.svelte
tools/ui/tests/unit/favicon-colorize.test.ts [new file with mode: 0644]

index ddcfe2e60f946b9fdcb260f60abf160017cc904f..0bb8c9b3c2189143e3982c0ec6d1f67de3ed87a3 100644 (file)
@@ -28,10 +28,9 @@ vite.config.ts.timestamp-*
 # PWA Artifacts
 apple-splash-*.png
 apple-touch-icon-*.png
-favicon.ico
-favicon-dark.ico
 maskable-icon-*.png
 pwa-*.png
+static/favicon*
 
 # Storybook
 *storybook.log
index 9d0cdfea6cbbfeddf062b00ee86e1800dcdce145..9dce3a0c9d4b4f9f344a68b9e422734d6afc24a0 100644 (file)
@@ -35,7 +35,7 @@
                                "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": {
index 4803922889a7e3ece07f7e4b437806dec1a666cb..bcb4165d10151b8eccd935dbb079c2c1fc3a2b1c 100644 (file)
@@ -54,7 +54,7 @@
                "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",
index e9793bca9eae65441b5253f8d214869785493832..358c0ebc074ef4fbb32837f7cc3e767d6099d815 100644 (file)
@@ -1,4 +1,10 @@
 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: {
@@ -7,7 +13,8 @@ export default defineConfig({
        preset: {
                transparent: {
                        sizes: [],
-                       favicons: [[48, 'favicon-dark.ico']]
+                       favicons: [[48, 'favicon-dark.ico']],
+                       padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
                },
                maskable: {
                        sizes: []
index 54928eeb4a8784024720d5a45034ecba0a31c16f..b69884d94a9edcb70b4bc8dede79db71bf61eb38 100644 (file)
@@ -5,15 +5,32 @@ import {
 } 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: {
diff --git a/tools/ui/scripts/favicon-colorize.ts b/tools/ui/scripts/favicon-colorize.ts
new file mode 100644 (file)
index 0000000..e1872b7
--- /dev/null
@@ -0,0 +1,107 @@
+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));
+}
index 9254a96df740f35ccd05e4c5a98db6981e2a5888..8c4056477dbf3fa070e3a09102d2f226e738469a 100644 (file)
@@ -48,6 +48,7 @@
 
        --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)));
 }
 
@@ -55,6 +56,7 @@
        :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;
+}
index d930448050d5ecb03ab25edf62e6b029d65b98cc..9a5918131aa90305239b005c72e3263bb73eeaf1 100644 (file)
@@ -10,9 +10,9 @@ import { isElementInViewport } from '$lib/utils/viewport';
  */
 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;
@@ -27,10 +27,12 @@ export function fadeInView(
                        (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();
                                        }
                                }
diff --git a/tools/ui/src/lib/assets/logo.svg b/tools/ui/src/lib/assets/logo.svg
new file mode 100644 (file)
index 0000000..0542479
--- /dev/null
@@ -0,0 +1,7 @@
+<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>
index f156df66998a8848af36b1c38632c1527202ca27..8a86557bb98d35d796af90ecc2b48c8b57776893 100644 (file)
@@ -8,12 +8,13 @@
                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;
        }
@@ -22,6 +23,7 @@
                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 />
index ed26f9ea58e431e61e16580a84e1d2c01c4b26f5..9b2077b8dcbd897a344227b8886a5125726af117 100644 (file)
        />
 
        <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
index 7175888aa261ddb460f2b2fa4e2bba636ca13b75..ad2a0def9ec24e22b695846ef86fa0f035fc9805 100644 (file)
@@ -15,7 +15,7 @@
 <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"
index c4069163f61b2a5110012761c93adc24e850ccf0..2b708aae539ee8992c648c73f4ed7dd41b0cf12e 100644 (file)
@@ -15,6 +15,7 @@
        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>
index 6a91bf90524286b2f30d61f1fa17609789fd1c4d..08d691c1438f1caf15fd4e4d1edd29e18ef93219 100644 (file)
@@ -42,6 +42,7 @@
                {hasMcpPromptsSupport}
                {hasMcpResourcesSupport}
                {onFileUpload}
+               {onSystemPromptClick}
                {onMcpPromptClick}
                {onMcpResourcesClick}
        >
index 8774bf63a6aa3cbf7a36285b98e8938b6e2a1f12..eff0364fa06568379f99b78971c132b4ae699f31 100644 (file)
@@ -20,7 +20,7 @@
                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'
                ]}
index 72e62f319a60b4a7b9b99f203a4694df56ca97d4..3e683389f18bb93047da5eda47675657ebd8eee8 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { isMobile } from '$lib/stores/viewport.svelte';
        import { autoResizeTextarea } from '$lib/utils';
        import { onMount } from 'svelte';
 
@@ -37,7 +38,9 @@
        }
 
        export function focus() {
-               textareaElement?.focus();
+               if (isMobile.current) return;
+
+               textareaElement?.focus({ preventScroll: true });
        }
 
        export function resetHeight() {
index 4d0b302d272efa140369d8f491d1a9cd1e6001c5..dadcae0c497d89de8a020228e6051f72d68cba2a 100644 (file)
                        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
index 2272eaedb397ea8a67abf123e5c1177bec034c20..a1d02d6ece7d6d037eaccd29fb1e1c6d85fbe213 100644 (file)
                        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;
index 96ec1ddfd746b5e5e436894ab272b7dc7a0959d8..80a0183e6bef56e9e823a339cc56a1649c07a140 100644 (file)
@@ -48,7 +48,7 @@
 
 <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}
index dabb337dd58a29f20ad6e6ba9720235ccaa45f6e..01fc9d3655050cfad9224eca8e4c07a84f07e1a2 100644 (file)
@@ -19,7 +19,7 @@
                renderMarkdown = false,
                textColorClass = 'text-foreground',
                cardBgClass = 'dark:bg-primary/15',
-               maxHeightStyle = 'max-height: var(--max-message-height);'
+               maxHeightStyle = ''
        }: Props = $props();
 
        let isMultiline = $state(false);
@@ -59,7 +59,7 @@
 
 {#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;"
        >
index 281e6ad0c5494a8c6afabe3bbc95641dccd877bd..2d959cfc2cc130079c313a0fdb750a501dea8721 100644 (file)
@@ -37,6 +37,7 @@
        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}
index e733a64a97a2667e0b11a6c077a49491523608e3..18635ba392ce8a50aeb23f086f3b475ef53151c9 100644 (file)
@@ -1,31 +1,28 @@
 <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}
 />
index a22c491adac62c62da2a294ae010d2bfd1cbc5c0..c470ac729690ceb93f95a963b00284fd79fb589c 100644 (file)
@@ -1,58 +1,18 @@
 <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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenDialogsAndAlerts.svelte
new file mode 100644 (file)
index 0000000..6305a74
--- /dev/null
@@ -0,0 +1,55 @@
+<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}
+/>
index aa1c0536dbc3b97f5cc33821f9b410ee1d7b9fd9..600180742a42dadf90e6ce9c32f8daad7d38b9fd 100644 (file)
@@ -2,6 +2,7 @@
        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
index 141d4f4e43c16aff19c6051ccaa04de608a8b5ee..018949aff87a96203e1278c70ca6116fdaaf3873 100644 (file)
@@ -1,5 +1,4 @@
 <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>
 
index f38f3519c3480f43fd22dbe0ac6dcfa6c1f537ab..0615d9f3009d791067d7919f8b7eee7ccafd7807 100644 (file)
@@ -5,13 +5,8 @@
        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());
@@ -70,8 +65,8 @@
 
 <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">
index 8ed3cc65ec80743586ad36d89e83a70ea57501e8..517f24d7407ba4be021d8d22ca86923ebc86ebb6 100644 (file)
@@ -677,13 +677,6 @@ export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
  */
 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.
index 19dd7e6a7efa8c8b548127edf86a24ec0a9404e5..c4389c52373afcea8896c0a91af1a597d02616c3 100644 (file)
@@ -3,6 +3,7 @@
        import { Search, X } from '@lucide/svelte';
 
        interface Props {
+               autofocus?: boolean;
                value?: string;
                placeholder?: string;
                onInput?: (value: string) => void;
@@ -15,6 +16,7 @@
        }
 
        let {
+               autofocus,
                value = $bindable(''),
                placeholder = 'Search...',
                onInput,
@@ -39,7 +41,7 @@
                if (value) {
                        value = '';
                        onInput?.('');
-                       ref?.focus();
+                       ref?.focus({ preventScroll: true });
                } else {
                        onClose?.();
                }
@@ -52,6 +54,7 @@
        />
 
        <Input
+               {autofocus}
                {id}
                bind:value
                bind:ref
diff --git a/tools/ui/src/lib/components/app/misc/Logo.svelte b/tools/ui/src/lib/components/app/misc/Logo.svelte
new file mode 100644 (file)
index 0000000..ff2b7f2
--- /dev/null
@@ -0,0 +1,15 @@
+<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>
index 64b76fb711dcafaa9eec088551ffb1ac3c7cba9e..b550ae66a53cf90735c31c63b4c5b3d42596cd00 100644 (file)
@@ -51,3 +51,11 @@ export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';
  * 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';
diff --git a/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte b/tools/ui/src/lib/components/app/navigation/DesktopIconStrip.svelte
deleted file mode 100644 (file)
index e92b952..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-<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>
index 1fa7722f24062239930e1da930468faf39c84650..1445961e00adabe233867a57449fd9d47b53c568 100644 (file)
@@ -1,40 +1,67 @@
 <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>
index f0d63970eea8be0810445bbf093bf7ca00a0a4bf..f118a68dfc670232d29fe5acf6c1e485296ae3ac 100644 (file)
@@ -1,39 +1,86 @@
 <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}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
new file mode 100644 (file)
index 0000000..488e96b
--- /dev/null
@@ -0,0 +1,135 @@
+<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}
index afc98470283af85e11c88e4f57fa8a30e574c23e..491e7c34798357269a8b25f37628f7c4faf94610 100644 (file)
@@ -16,4 +16,6 @@
        }: 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>
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearchResults.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearchResults.svelte
new file mode 100644 (file)
index 0000000..92d8fd0
--- /dev/null
@@ -0,0 +1,76 @@
+<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>
index d4ca914594c066b6cad05b1108b90f1eaa68408a..e07dde6bc9012d6b69ba2bfc391c90c80545223b 100644 (file)
@@ -63,15 +63,6 @@ export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svel
  * ```
  */
 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
  *
@@ -115,13 +106,6 @@ export { default as DesktopIconStrip } from './DesktopIconStrip.svelte';
  */
 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
@@ -130,6 +114,58 @@ export { default as SidebarNavigationActions } from './SidebarNavigation/Sidebar
  */
 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.
index 62e73d8579ac24173ec45cfcbdd71f6c58538f93..41105baa5e629b87ee4b5f5babcc153ed9b382e8 100644 (file)
        });
 </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}
index 48a7b8757b86d137216caf560e739ddb168407ee..8723217b29a90662b05b6978ecbf595884794138 100644 (file)
        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}
index 5c801fa2c2737ea7d89012bb3e56afc28fca4c82..e6f4b7e39d298983679b51592d08ddbf2520eb39 100644 (file)
@@ -1,17 +1,19 @@
 <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
index cbf4d2cc5915f1b9684abb9a15adfe9497e20f6c..165b9fd6895335faddfa5eb60b30a8890cce00cd 100644 (file)
@@ -20,7 +20,7 @@
                        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'
diff --git a/tools/ui/src/lib/components/ui/sidebar/constants.ts b/tools/ui/src/lib/components/ui/sidebar/constants.ts
deleted file mode 100644 (file)
index 8c320c2..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-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';
diff --git a/tools/ui/src/lib/components/ui/sidebar/context.svelte.ts b/tools/ui/src/lib/components/ui/sidebar/context.svelte.ts
deleted file mode 100644 (file)
index 2fa5cc2..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-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));
-}
diff --git a/tools/ui/src/lib/components/ui/sidebar/index.ts b/tools/ui/src/lib/components/ui/sidebar/index.ts
deleted file mode 100644 (file)
index 280e640..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-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
-};
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-content.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-content.svelte
deleted file mode 100644 (file)
index 0e5f75e..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-footer.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-footer.svelte
deleted file mode 100644 (file)
index 69914df..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-group-action.svelte
deleted file mode 100644 (file)
index 027a711..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<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}
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-group-content.svelte
deleted file mode 100644 (file)
index 9e018fb..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-group-label.svelte
deleted file mode 100644 (file)
index 79f47d7..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-<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}
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-group.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-group.svelte
deleted file mode 100644 (file)
index eed5ace..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-header.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-header.svelte
deleted file mode 100644 (file)
index 0651550..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-input.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-input.svelte
deleted file mode 100644 (file)
index fa57473..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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}
-/>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-inset.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-inset.svelte
deleted file mode 100644 (file)
index f55d2f4..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
deleted file mode 100644 (file)
index ded1ffd..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<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}
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
deleted file mode 100644 (file)
index f4525a1..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
deleted file mode 100644 (file)
index f9efd67..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-<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}
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
deleted file mode 100644 (file)
index 5adbedd..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
deleted file mode 100644 (file)
index 2b2acd6..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
deleted file mode 100644 (file)
index dabfe0f..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<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}
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
deleted file mode 100644 (file)
index cca870e..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
deleted file mode 100644 (file)
index 5458ced..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-menu.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-menu.svelte
deleted file mode 100644 (file)
index fee96ed..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-provider.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-provider.svelte
deleted file mode 100644 (file)
index 9fd2704..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-rail.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-rail.svelte
deleted file mode 100644 (file)
index cde9307..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-separator.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-separator.svelte
deleted file mode 100644 (file)
index 8fc2065..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<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}
-/>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar-trigger.svelte
deleted file mode 100644 (file)
index 67076d4..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<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>
diff --git a/tools/ui/src/lib/components/ui/sidebar/sidebar.svelte b/tools/ui/src/lib/components/ui/sidebar/sidebar.svelte
deleted file mode 100644 (file)
index d6da1ae..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-<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}
index 49036a67be3457009fc934836ab3f37cfe835d85..80505fabdc5196f49af0e734efba332d70353df4 100644 (file)
@@ -7,7 +7,8 @@ import { APP_NAME } from './app';
 
 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 = {
@@ -34,6 +35,13 @@ export const FAVICON_PATHS = {
        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"]'
@@ -222,8 +230,13 @@ export const PWA_GENERATOR_DEVICES = [
 ] 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,
index 3b3fceea448ea5e5555305bc97d91a1e4560b2ed..1adf0a600588d5db51cd5ea55d3a869cafd49ae8 100644 (file)
@@ -23,5 +23,7 @@ export const ROUTES = {
        /** 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;
index 6f344029f3cda9decb2d0afb285cd24b73550e75..f2d3e95e01c648ef5dfaf5e42293b19f2a3c05df 100644 (file)
@@ -1,4 +1,4 @@
-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';
@@ -15,6 +15,7 @@ export interface DesktopIconStripItem {
        route?: string;
        activeRouteId?: string;
        activeRoutePrefix?: string;
+       activeUrlIncludes?: string;
        keys?: string[];
 }
 
@@ -30,7 +31,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
        {
                icon: Settings,
                tooltip: 'Settings',
-               route: ROUTES.SETTINGS,
-               activeRoutePrefix: '/settings'
+               route: `${ROUTES.SETTINGS}/general`,
+               activeUrlIncludes: '#/settings'
        }
 ];
index 7bac452e4e9dbf2dc3c07587beadd4ac7fc38958..8107f08d088eaf85ae538c68d59fb3fff0d674bc 100644 (file)
@@ -105,7 +105,10 @@ export class AutoScrollController {
         */
        resetScrollState(): void {
                this._userScrolledUp = false;
-               this._autoScrollEnabled = true;
+               this._autoScrollEnabled = !this._disabled;
+               if (this._container) {
+                       this._lastScrollTop = this._container.scrollTop;
+               }
        }
 
        /**
diff --git a/tools/ui/src/lib/hooks/use-chat-screen-active-model.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-active-model.svelte.ts
new file mode 100644 (file)
index 0000000..de75e9f
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * 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;
+               }
+       };
+}
diff --git a/tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-drag-and-drop.svelte.ts
new file mode 100644 (file)
index 0000000..3f292b4
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * 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
+               }
+       };
+}
diff --git a/tools/ui/src/lib/hooks/use-chat-screen-file-upload.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-file-upload.svelte.ts
new file mode 100644 (file)
index 0000000..f1ab947
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * 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
+       };
+}
diff --git a/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts
new file mode 100644 (file)
index 0000000..cecf208
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * 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
+       };
+}
index 098cb2c27aaf2e071987e6014a4122cb84d8341a..9b3be15c03baa4e7254750a2f6991d8519b4c191 100644 (file)
@@ -143,7 +143,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
                                        '[data-slot="chat-form"] textarea'
                                );
 
-                               textarea?.focus();
+                               textarea?.focus({ preventScroll: true });
                        });
                }
 
diff --git a/tools/ui/src/lib/stores/device.svelte.ts b/tools/ui/src/lib/stores/device.svelte.ts
new file mode 100644 (file)
index 0000000..d0f04b4
--- /dev/null
@@ -0,0 +1,72 @@
+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;
+       });
+}
index 87fb172d07be80776ee21b7d943c89a4eb81987b..11316a193c5baf99255906942a0653a75aa50bf7 100644 (file)
@@ -576,7 +576,7 @@ class ModelsStore {
                }
 
                // 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;
index 1269692a78cd37265219edf71e75aa0b82311848..f7993f5947f67404f9fe10f23e14ba251b1d6054 100644 (file)
@@ -6,18 +6,12 @@
        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';
@@ -31,7 +25,6 @@
        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';
@@ -42,8 +35,6 @@
        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);
 
@@ -61,7 +52,6 @@
        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>
diff --git a/tools/ui/src/routes/search/+page.svelte b/tools/ui/src/routes/search/+page.svelte
new file mode 100644 (file)
index 0000000..d99dc1f
--- /dev/null
@@ -0,0 +1,95 @@
+<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>
index b3b3d30e0bd7c0d9dafcdf77878a12559db4518c..c2b587862f257d149d2f57b2b007d80be8020d5d 100644 (file)
        }
 </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>
index 22e727f175b14181af134c807068088fc1522268..90fe6107012fae5650554d9685bd4e71ed1418f1 100644 (file)
@@ -1,12 +1,11 @@
 <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), {});
                }
diff --git a/tools/ui/static/favicon-dark.svg b/tools/ui/static/favicon-dark.svg
deleted file mode 100644 (file)
index 22cab8d..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<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>
diff --git a/tools/ui/static/favicon.svg b/tools/ui/static/favicon.svg
deleted file mode 100644 (file)
index 52664fc..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<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>
index aeb7ff74c4aadd77eafa3e36682b7a4530b07a67..1380ec851bd7fdef05a188641e5377d5a0e3700e 100644 (file)
@@ -1,9 +1,6 @@
 <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>
 
 <!--
@@ -11,7 +8,5 @@
        This mirrors the providers from +layout.svelte.
 -->
 <Tooltip.Provider>
-       <Sidebar.Provider bind:open={sidebarOpen}>
-               <Page />
-       </Sidebar.Provider>
+       <Page />
 </Tooltip.Provider>
index aae42f2a053cf6d99d55110b052b8a71fe636046..7fbcef3ed5bd1c717e3cba4308f65a521a7efe1c 100644 (file)
 </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>
diff --git a/tools/ui/tests/unit/favicon-colorize.test.ts b/tools/ui/tests/unit/favicon-colorize.test.ts
new file mode 100644 (file)
index 0000000..12b8a6f
--- /dev/null
@@ -0,0 +1,198 @@
+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);
+       });
+});