ui: New Logo + Navigation cleanup & Mobile UI/UX improvements (#24897)
* 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 <175728472+Copilot@users.noreply.github.com> * 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 <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
88636e178f
commit
ef9c13d4c2
+1
-2
@@ -28,10 +28,9 @@ vite.config.ts.timestamp-*
|
|||||||
# PWA Artifacts
|
# PWA Artifacts
|
||||||
apple-splash-*.png
|
apple-splash-*.png
|
||||||
apple-touch-icon-*.png
|
apple-touch-icon-*.png
|
||||||
favicon.ico
|
|
||||||
favicon-dark.ico
|
|
||||||
maskable-icon-*.png
|
maskable-icon-*.png
|
||||||
pwa-*.png
|
pwa-*.png
|
||||||
|
static/favicon*
|
||||||
|
|
||||||
# Storybook
|
# Storybook
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
|||||||
Generated
+7
-7
@@ -35,7 +35,7 @@
|
|||||||
"bits-ui": "2.18.1",
|
"bits-ui": "2.18.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"dexie": "4.4.3",
|
"dexie": "4.4.3",
|
||||||
"dompurify": "3.4.5",
|
"dompurify": "3.4.11",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-storybook": "10.4.2",
|
"eslint-plugin-storybook": "10.4.2",
|
||||||
@@ -8653,9 +8653,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.5",
|
"version": "3.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
@@ -10226,9 +10226,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.23",
|
"version": "4.12.26",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
|
||||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"bits-ui": "2.18.1",
|
"bits-ui": "2.18.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"dexie": "4.4.3",
|
"dexie": "4.4.3",
|
||||||
"dompurify": "3.4.5",
|
"dompurify": "3.4.11",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-storybook": "10.4.2",
|
"eslint-plugin-storybook": "10.4.2",
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { defineConfig } from '@vite-pwa/assets-generator/config';
|
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({
|
export default defineConfig({
|
||||||
headLinkOptions: {
|
headLinkOptions: {
|
||||||
@@ -7,7 +13,8 @@ export default defineConfig({
|
|||||||
preset: {
|
preset: {
|
||||||
transparent: {
|
transparent: {
|
||||||
sizes: [],
|
sizes: [],
|
||||||
favicons: [[48, 'favicon-dark.ico']]
|
favicons: [[48, 'favicon-dark.ico']],
|
||||||
|
padding: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||||
},
|
},
|
||||||
maskable: {
|
maskable: {
|
||||||
sizes: []
|
sizes: []
|
||||||
|
|||||||
@@ -5,15 +5,32 @@ import {
|
|||||||
} from '@vite-pwa/assets-generator/config';
|
} from '@vite-pwa/assets-generator/config';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
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 { 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({
|
export default defineConfig({
|
||||||
headLinkOptions: {
|
headLinkOptions: {
|
||||||
preset: PWA_ASSET_GENERATOR.LINK_PRESET
|
preset: PWA_ASSET_GENERATOR.LINK_PRESET
|
||||||
},
|
},
|
||||||
preset: combinePresetAndAppleSplashScreens(
|
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,
|
padding: PWA_ASSET_GENERATOR.SPLASH_PADDING,
|
||||||
resizeOptions: {
|
resizeOptions: {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
|
|
||||||
--chat-form-area-height: 8rem;
|
--chat-form-area-height: 8rem;
|
||||||
--chat-form-area-offset: 2rem;
|
--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)));
|
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--chat-form-area-height: 24rem;
|
--chat-form-area-height: 24rem;
|
||||||
--chat-form-area-offset: 12rem;
|
--chat-form-area-offset: 12rem;
|
||||||
|
--chat-form-padding-top: 6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +143,6 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
overflow: hidden; /* Added due to Mermaid rendering somehow causing the double scrollbar */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global scrollbar styling - visible only on hover */
|
/* Global scrollbar styling - visible only on hover */
|
||||||
@@ -193,3 +194,7 @@
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mermaidTooltip {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { isElementInViewport } from '$lib/utils/viewport';
|
|||||||
*/
|
*/
|
||||||
export function fadeInView(
|
export function fadeInView(
|
||||||
node: HTMLElement,
|
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)) {
|
if (skipIfVisible && isElementInViewport(node)) {
|
||||||
return;
|
return;
|
||||||
@@ -27,10 +27,12 @@ export function fadeInView(
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
node.style.opacity = '1';
|
requestAnimationFrame(() => {
|
||||||
node.style.transform = 'translateY(0)';
|
node.style.opacity = '1';
|
||||||
});
|
node.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 771 B |
@@ -8,12 +8,13 @@
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
href?: string;
|
||||||
icon: Component;
|
icon: Component;
|
||||||
iconSize?: string;
|
iconSize?: string;
|
||||||
onclick: (e?: MouseEvent) => void;
|
onclick?: (e?: MouseEvent) => void;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
stopPropagationOnClick?: boolean;
|
stopPropagationOnClick?: boolean;
|
||||||
tooltip: string;
|
tooltip?: string;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
tooltipSide?: TooltipSide;
|
tooltipSide?: TooltipSide;
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
icon,
|
icon,
|
||||||
tooltip,
|
tooltip,
|
||||||
variant = 'ghost',
|
variant = 'ghost',
|
||||||
|
href = '',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
class: className = '',
|
class: className = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -31,34 +33,49 @@
|
|||||||
onclick,
|
onclick,
|
||||||
ariaLabel
|
ariaLabel
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let innerWidth = $state(0);
|
||||||
|
const showTooltip = $derived(!!tooltip && innerWidth > 768);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
{#snippet button(props = {})}
|
||||||
<Tooltip.Trigger>
|
<Button
|
||||||
<!-- prevent another nested button element -->
|
{...props}
|
||||||
{#snippet child({ props })}
|
{href}
|
||||||
<Button
|
{variant}
|
||||||
{...props}
|
{size}
|
||||||
{variant}
|
{disabled}
|
||||||
{size}
|
onclick={(e: MouseEvent) => {
|
||||||
{disabled}
|
if (stopPropagationOnClick) e.stopPropagation();
|
||||||
onclick={(e: MouseEvent) => {
|
|
||||||
if (stopPropagationOnClick) e.stopPropagation();
|
|
||||||
|
|
||||||
onclick?.(e);
|
onclick?.(e);
|
||||||
}}
|
}}
|
||||||
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
|
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
|
||||||
aria-label={ariaLabel || tooltip}
|
aria-label={ariaLabel || tooltip}
|
||||||
>
|
>
|
||||||
{#if icon}
|
{#if icon}
|
||||||
{@const IconComponent = icon}
|
{@const IconComponent = icon}
|
||||||
<IconComponent class={iconSize} />
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
|
|
||||||
<Tooltip.Content side={tooltipSide}>
|
<IconComponent class={iconSize} />
|
||||||
<p>{tooltip}</p>
|
{/if}
|
||||||
</Tooltip.Content>
|
</Button>
|
||||||
</Tooltip.Root>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if showTooltip}
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<!-- prevent another nested button element -->
|
||||||
|
{#snippet child({ props })}
|
||||||
|
{@render button(props)}
|
||||||
|
{/snippet}
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
|
||||||
|
<Tooltip.Content side={tooltipSide}>
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
{:else}
|
||||||
|
{@render button({ href })}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth />
|
||||||
|
|||||||
@@ -494,7 +494,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<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'
|
? 'cursor-not-allowed opacity-60'
|
||||||
: ''}"
|
: ''}"
|
||||||
data-slot="input-area"
|
data-slot="input-area"
|
||||||
@@ -510,7 +510,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<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}
|
onpaste={handlePaste}
|
||||||
>
|
>
|
||||||
<ChatFormTextarea
|
<ChatFormTextarea
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger class="w-full">
|
<Tooltip.Trigger class="w-full">
|
||||||
<Button
|
<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}
|
{disabled}
|
||||||
{onclick}
|
{onclick}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
+16
-3
@@ -15,6 +15,7 @@
|
|||||||
import { McpLogo } from '$lib/components/app';
|
import { McpLogo } from '$lib/components/app';
|
||||||
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
|
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
|
import { AttachmentAction } from '$lib/enums/attachment.enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -270,14 +271,22 @@
|
|||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
{/if}
|
{/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" />
|
<MessageSquare class="h-4 w-4 shrink-0" />
|
||||||
|
|
||||||
<span>System Message</span>
|
<span>System Message</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if hasMcpPromptsSupport}
|
{#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" />
|
<Zap class="h-4 w-4 shrink-0" />
|
||||||
|
|
||||||
<span>MCP Prompt</span>
|
<span>MCP Prompt</span>
|
||||||
@@ -285,7 +294,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasMcpResourcesSupport}
|
{#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" />
|
<FolderOpen class="h-4 w-4 shrink-0" />
|
||||||
|
|
||||||
<span>MCP Resources</span>
|
<span>MCP Resources</span>
|
||||||
|
|||||||
+1
@@ -42,6 +42,7 @@
|
|||||||
{hasMcpPromptsSupport}
|
{hasMcpPromptsSupport}
|
||||||
{hasMcpResourcesSupport}
|
{hasMcpResourcesSupport}
|
||||||
{onFileUpload}
|
{onFileUpload}
|
||||||
|
{onSystemPromptClick}
|
||||||
{onMcpPromptClick}
|
{onMcpPromptClick}
|
||||||
{onMcpResourcesClick}
|
{onMcpResourcesClick}
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
class={[
|
class={[
|
||||||
'h-8 w-8 rounded-full p-0',
|
'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
|
||||||
showErrorState &&
|
showErrorState &&
|
||||||
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
|
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
import { autoResizeTextarea } from '$lib/utils';
|
import { autoResizeTextarea } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -37,7 +38,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function focus() {
|
export function focus() {
|
||||||
textareaElement?.focus();
|
if (isMobile.current) return;
|
||||||
|
|
||||||
|
textareaElement?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetHeight() {
|
export function resetHeight() {
|
||||||
|
|||||||
@@ -231,7 +231,7 @@
|
|||||||
editedContent = message.content;
|
editedContent = message.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
textareaElement?.focus();
|
textareaElement?.focus({ preventScroll: true });
|
||||||
editedExtras = message.extra ? [...message.extra] : [];
|
editedExtras = message.extra ? [...message.extra] : [];
|
||||||
editedUploadedFiles = [];
|
editedUploadedFiles = [];
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div use:fadeInView>
|
<div use:fadeInView class="chat-message">
|
||||||
{#if message.role === MessageRole.SYSTEM}
|
{#if message.role === MessageRole.SYSTEM}
|
||||||
<ChatMessageSystem
|
<ChatMessageSystem
|
||||||
bind:textareaElement
|
bind:textareaElement
|
||||||
|
|||||||
+61
-3
@@ -207,6 +207,42 @@
|
|||||||
isLastAssistantMessage
|
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() {
|
function handleCopyModel() {
|
||||||
void copyToClipboard(displayedModel ?? '');
|
void copyToClipboard(displayedModel ?? '');
|
||||||
}
|
}
|
||||||
@@ -219,12 +255,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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"
|
role="group"
|
||||||
aria-label="Assistant message with actions"
|
aria-label="Assistant message with actions"
|
||||||
>
|
>
|
||||||
{#if showProcessingInfoTop}
|
{#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">
|
<div class="processing-container">
|
||||||
<span class="processing-text">
|
<span class="processing-text">
|
||||||
{modelLoadingText ??
|
{modelLoadingText ??
|
||||||
@@ -257,7 +298,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showProcessingInfoBottom}
|
{#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">
|
<div class="processing-container">
|
||||||
<span class="processing-text">
|
<span class="processing-text">
|
||||||
{modelLoadingText ??
|
{modelLoadingText ??
|
||||||
@@ -351,6 +392,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.processing-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
aria-label="User message with actions"
|
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"
|
role="group"
|
||||||
>
|
>
|
||||||
{#if editCtx.isEditing}
|
{#if editCtx.isEditing}
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@
|
|||||||
renderMarkdown = false,
|
renderMarkdown = false,
|
||||||
textColorClass = 'text-foreground',
|
textColorClass = 'text-foreground',
|
||||||
cardBgClass = 'dark:bg-primary/15',
|
cardBgClass = 'dark:bg-primary/15',
|
||||||
maxHeightStyle = 'max-height: var(--max-message-height);'
|
maxHeightStyle = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isMultiline = $state(false);
|
let isMultiline = $state(false);
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
{#if content.trim()}
|
{#if content.trim()}
|
||||||
<Card
|
<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}
|
data-multiline={isMultiline ? '' : undefined}
|
||||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||||
let isVisible = $state(false);
|
let isVisible = $state(false);
|
||||||
let previousConversationId = $state<string | null>(null);
|
let previousConversationId = $state<string | null>(null);
|
||||||
|
let previousRouteId = $state<string | null>(null);
|
||||||
|
|
||||||
const currentConfig = config();
|
const currentConfig = config();
|
||||||
|
|
||||||
@@ -157,8 +158,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate((navigation) => {
|
||||||
isVisible = false;
|
isVisible = false;
|
||||||
|
previousRouteId = navigation.from?.route.id ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
@@ -249,12 +251,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="transition-opacity delay-300 duration-500 ease-out
|
class="transition-opacity duration-500 ease-out
|
||||||
{isVisible ? 'opacity-100' : 'opacity-0'}"
|
{isVisible ? 'opacity-100' : 'opacity-0'}
|
||||||
|
{previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
|
||||||
>
|
>
|
||||||
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
|
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
class="mx-auto mt-12 w-full max-w-3xl"
|
||||||
{message}
|
{message}
|
||||||
{toolMessages}
|
{toolMessages}
|
||||||
{isLastAssistantMessage}
|
{isLastAssistantMessage}
|
||||||
|
|||||||
@@ -1,31 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Trash2 } from '@lucide/svelte';
|
|
||||||
import { afterNavigate } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import {
|
import {
|
||||||
ChatScreenForm,
|
ChatScreenForm,
|
||||||
ChatMessages,
|
ChatMessages,
|
||||||
ChatScreenDragOverlay,
|
ChatScreenDragOverlay,
|
||||||
ChatScreenProcessingInfo,
|
ChatScreenProcessingInfo,
|
||||||
ChatScreenActionScrollDown,
|
|
||||||
DialogEmptyFileAlert,
|
|
||||||
DialogFileUploadError,
|
|
||||||
DialogChatError,
|
|
||||||
ServerLoadingSplash,
|
ServerLoadingSplash,
|
||||||
DialogConfirmation,
|
|
||||||
ChatScreenServerError
|
ChatScreenServerError
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { setProcessingInfoContext } from '$lib/contexts';
|
import { setProcessingInfoContext } from '$lib/contexts';
|
||||||
import { ErrorDialogType } from '$lib/enums';
|
|
||||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
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 { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||||
|
import { device } from '$lib/stores/device.svelte';
|
||||||
|
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
import {
|
import {
|
||||||
chatStore,
|
chatStore,
|
||||||
errorDialog,
|
errorDialog,
|
||||||
isLoading,
|
isLoading,
|
||||||
isChatStreaming,
|
isChatStreaming,
|
||||||
isEditing,
|
isEditing,
|
||||||
getAddFilesHandler,
|
|
||||||
activeProcessingState
|
activeProcessingState
|
||||||
} from '$lib/stores/chat.svelte';
|
} from '$lib/stores/chat.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -34,138 +31,81 @@
|
|||||||
activeConversation
|
activeConversation
|
||||||
} from '$lib/stores/conversations.svelte';
|
} from '$lib/stores/conversations.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { serverLoading, serverError, isRouterMode } from '$lib/stores/server.svelte';
|
import { serverLoading, serverError } from '$lib/stores/server.svelte';
|
||||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||||
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import ChatScreenGreeting from './ChatScreenGreeting.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();
|
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: []
|
|
||||||
});
|
|
||||||
|
|
||||||
let showDeleteDialog = $state(false);
|
|
||||||
|
|
||||||
let showEmptyFileDialog = $state(false);
|
|
||||||
|
|
||||||
let processingInfoVisible = $state(false);
|
|
||||||
|
|
||||||
let emptyFileNames = $state<string[]>([]);
|
|
||||||
|
|
||||||
let initialMessage = $state('');
|
|
||||||
|
|
||||||
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 modelPropsVersion = $state(0);
|
|
||||||
|
|
||||||
setProcessingInfoContext({
|
setProcessingInfoContext({
|
||||||
get showProcessingInfo() {
|
get showProcessingInfo() {
|
||||||
return showProcessingInfo;
|
return showProcessingInfo;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll) || isMobile.current);
|
||||||
if (activeModelId) {
|
let isMobileUserScrolledUp = $state(false);
|
||||||
const cached = modelsStore.getModelProps(activeModelId);
|
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 chatFormBottomPosition = $derived.by(() => {
|
||||||
|
if (!isMobile.current) return '1rem';
|
||||||
|
if (device.isStandalone) return '1.5rem';
|
||||||
|
if (device.isIOSSafari) return '0.25rem';
|
||||||
|
return '0.5rem';
|
||||||
|
});
|
||||||
|
|
||||||
if (!cached) {
|
const autoScroll = createAutoScrollController();
|
||||||
modelsStore.fetchModelProps(activeModelId).then(() => {
|
const scroll = useChatScreenScroll(autoScroll);
|
||||||
modelPropsVersion++;
|
const activeModel = useChatScreenActiveModel();
|
||||||
});
|
const fileUpload = useChatScreenFileUpload({
|
||||||
|
capabilities: () => ({
|
||||||
|
hasVision: activeModel.hasVisionModality,
|
||||||
|
hasAudio: activeModel.hasAudioModality,
|
||||||
|
hasVideo: activeModel.hasVideoModality
|
||||||
|
}),
|
||||||
|
activeModelId: () => activeModel.activeModelId
|
||||||
|
});
|
||||||
|
const dragAndDrop = useChatScreenDragAndDrop({
|
||||||
|
onDrop: fileUpload.handleFileUpload
|
||||||
|
});
|
||||||
|
const { handleKeydown } = useKeyboardShortcuts({
|
||||||
|
deleteActiveConversation: () => {
|
||||||
|
if (activeConversation()) {
|
||||||
|
showDeleteDialog = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let hasAudioModality = $derived.by(() => {
|
function handleMobileScroll() {
|
||||||
if (activeModelId) {
|
if (!isMobile.current) return;
|
||||||
void modelPropsVersion;
|
|
||||||
|
|
||||||
return modelsStore.modelSupportsAudio(activeModelId);
|
const container = scroll.chatScrollContainer;
|
||||||
}
|
if (!container) return;
|
||||||
|
|
||||||
return false;
|
const distanceFromBottom =
|
||||||
});
|
container.scrollHeight - container.clientHeight - container.scrollTop;
|
||||||
|
isMobileUserScrolledUp = distanceFromBottom > 300;
|
||||||
let hasVideoModality = $derived.by(() => {
|
}
|
||||||
if (activeModelId) {
|
|
||||||
void modelPropsVersion;
|
|
||||||
|
|
||||||
return modelsStore.modelSupportsVideo(activeModelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
let hasVisionModality = $derived.by(() => {
|
|
||||||
if (activeModelId) {
|
|
||||||
void modelPropsVersion;
|
|
||||||
|
|
||||||
return modelsStore.modelSupportsVision(activeModelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleDeleteConfirm() {
|
async function handleDeleteConfirm() {
|
||||||
const conversation = activeConversation();
|
const conversation = activeConversation();
|
||||||
@@ -177,27 +117,69 @@
|
|||||||
showDeleteDialog = false;
|
showDeleteDialog = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleProcessingInfoVisibility(visible: boolean) {
|
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
|
||||||
processingInfoVisible = visible;
|
const plainFiles = files ? $state.snapshot(files) : undefined;
|
||||||
}
|
const result = plainFiles
|
||||||
|
? await parseFilesToMessageExtras(plainFiles, activeModel.activeModelId ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
function handleDragEnter(event: DragEvent) {
|
if (result?.emptyFiles && result.emptyFiles.length > 0) {
|
||||||
event.preventDefault();
|
emptyFileNames = result.emptyFiles;
|
||||||
|
showEmptyFileDialog = true;
|
||||||
dragCounter++;
|
if (files) {
|
||||||
|
const emptyFileNamesSet = new Set(result.emptyFiles);
|
||||||
if (event.dataTransfer?.types.includes('Files')) {
|
fileUpload.uploadedFiles = fileUpload.uploadedFiles.filter(
|
||||||
isDragOver = true;
|
(file) => !emptyFileNamesSet.has(file.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSendLikeScroll();
|
||||||
|
|
||||||
|
await chatStore.sendMessage(message, result?.extras);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave(event: DragEvent) {
|
function handleSendLikeScroll() {
|
||||||
event.preventDefault();
|
if (!isMobile.current) {
|
||||||
|
autoScroll.enable();
|
||||||
|
}
|
||||||
|
|
||||||
dragCounter--;
|
setTimeout(() => {
|
||||||
|
const container = scroll.chatScrollContainer;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
if (dragCounter === 0) {
|
const lastUserBubble = container.querySelector(
|
||||||
isDragOver = false;
|
'.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 {
|
||||||
|
autoScroll.scrollToBottom();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
if (isMobile.current) {
|
||||||
|
autoScroll.setDisabled(disableAutoScroll);
|
||||||
|
mobileScrollDownHint = true;
|
||||||
|
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,273 +189,138 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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[] }) {
|
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
|
||||||
if (draft.message || draft.files.length > 0) {
|
if (draft.message || draft.files.length > 0) {
|
||||||
chatStore.savePendingDraft(draft.message, draft.files);
|
chatStore.savePendingDraft(draft.message, draft.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
await chatStore.addSystemPrompt();
|
await chatStore.addSystemPrompt();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
$effect(() => {
|
||||||
autoScroll.handleScroll();
|
const shouldDisableAutoScroll =
|
||||||
}
|
config().disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
|
||||||
|
autoScroll.setDisabled(shouldDisableAutoScroll);
|
||||||
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
|
if (!shouldDisableAutoScroll) {
|
||||||
const plainFiles = files ? $state.snapshot(files) : undefined;
|
|
||||||
const result = plainFiles
|
|
||||||
? await parseFilesToMessageExtras(plainFiles, 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));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extras = result?.extras;
|
|
||||||
|
|
||||||
// Enable autoscroll for user-initiated message sending
|
|
||||||
autoScroll.enable();
|
|
||||||
await chatStore.sendMessage(message, extras);
|
|
||||||
autoScroll.scrollToBottom();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportedFiles.length > 0) {
|
|
||||||
const processed = await processFilesToChatUploaded(
|
|
||||||
supportedFiles,
|
|
||||||
activeModelId ?? undefined
|
|
||||||
);
|
|
||||||
uploadedFiles = [...uploadedFiles, ...processed];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterNavigate(() => {
|
|
||||||
if (!disableAutoScroll) {
|
|
||||||
autoScroll.enable();
|
autoScroll.enable();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleMessagesReady() {
|
|
||||||
if (disableAutoScroll) return;
|
|
||||||
|
|
||||||
if (!autoScroll.userScrolledUp) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
autoScroll.scrollToBottom('instant');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const pendingDraft = chatStore.consumePendingDraft();
|
||||||
|
if (pendingDraft) {
|
||||||
|
initialMessage = pendingDraft.message;
|
||||||
|
fileUpload.uploadedFiles = pendingDraft.files;
|
||||||
|
}
|
||||||
|
|
||||||
autoScroll.startObserving();
|
autoScroll.startObserving();
|
||||||
|
|
||||||
if (!disableAutoScroll) {
|
if (!disableAutoScroll) {
|
||||||
autoScroll.enable();
|
autoScroll.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingDraft = chatStore.consumePendingDraft();
|
if (isMobile.current && isCurrentConversationLoading) {
|
||||||
if (pendingDraft) {
|
mobileScrollDownHint = true;
|
||||||
initialMessage = pendingDraft.message;
|
mobileScrollDownHintLockedUntil = Date.now() + 500;
|
||||||
uploadedFiles = pendingDraft.files;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMobileScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
onDestroy(() => autoScroll.destroy());
|
||||||
autoScroll.setContainer(chatScrollContainer);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
autoScroll.setDisabled(disableAutoScroll);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isDragOver}
|
{#if dragAndDrop.isDragOver}
|
||||||
<ChatScreenDragOverlay />
|
<ChatScreenDragOverlay />
|
||||||
{/if}
|
{/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}
|
{#if isServerLoading}
|
||||||
<ServerLoadingSplash />
|
<ServerLoadingSplash />
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
bind:this={chatScrollContainer}
|
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"
|
||||||
aria-label="Chat interface with file drop zone"
|
style:--chat-form-bottom-position={chatFormBottomPosition}
|
||||||
class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
|
ondragenter={dragAndDrop.dragHandlers.dragenter}
|
||||||
ondragenter={handleDragEnter}
|
ondragleave={dragAndDrop.dragHandlers.dragleave}
|
||||||
ondragleave={handleDragLeave}
|
ondragover={dragAndDrop.dragHandlers.dragover}
|
||||||
ondragover={handleDragOver}
|
ondrop={dragAndDrop.dragHandlers.drop}
|
||||||
ondrop={handleDrop}
|
|
||||||
onscroll={handleScroll}
|
|
||||||
role="main"
|
role="main"
|
||||||
>
|
>
|
||||||
<div class="flex grow flex-col pt-14">
|
{#if !isEmpty}
|
||||||
{#if !isEmpty}
|
<ChatMessages
|
||||||
<ChatMessages
|
messages={activeMessages()}
|
||||||
messages={activeMessages()}
|
onUserAction={() => {
|
||||||
onMessagesReady={handleMessagesReady}
|
handleSendLikeScroll();
|
||||||
onUserAction={() => {
|
}}
|
||||||
autoScroll.enable();
|
/>
|
||||||
if (!autoScroll.userScrolledUp) {
|
{/if}
|
||||||
autoScroll.scrollToBottom();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'pointer-events-none sticky right-4 left-4 mt-auto transition-all duration-200',
|
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
||||||
isEmpty ? 'bottom-[calc(50dvh-7rem)]' : 'bottom-4 pt-24 md:pt-32'
|
device.isStandalone
|
||||||
]}
|
? 'bottom-6 right-4 left-4'
|
||||||
>
|
: device.isIOSSafari
|
||||||
<ChatScreenGreeting {isEmpty} />
|
? '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} />
|
||||||
|
|
||||||
<ChatScreenActionScrollDown
|
<ChatScreenServerError />
|
||||||
container={chatScrollContainer}
|
|
||||||
hasProcessingInfoVisible={processingInfoVisible}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChatScreenProcessingInfo onVisibilityChange={handleProcessingInfoVisibility} />
|
<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}
|
||||||
<ChatScreenServerError />
|
<ChatScreenActionScrollDown
|
||||||
|
onclick={() => {
|
||||||
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
|
mobileScrollDownHint = false;
|
||||||
<ChatScreenForm
|
scroll.chatScrollContainer?.scrollTo({
|
||||||
disabled={hasPropsError || isEditing()}
|
top: scroll.chatScrollContainer.scrollHeight,
|
||||||
{initialMessage}
|
behavior: 'smooth'
|
||||||
isLoading={isCurrentConversationLoading}
|
});
|
||||||
onFileRemove={handleFileRemove}
|
}}
|
||||||
onFileUpload={handleFileUpload}
|
|
||||||
onSend={handleSendMessage}
|
|
||||||
onStop={() => chatStore.stopGeneration()}
|
|
||||||
onSystemPromptAdd={handleSystemPromptAdd}
|
|
||||||
bind:uploadedFiles
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
|
{#if showProcessingInfo}
|
||||||
|
<ChatScreenProcessingInfo />
|
||||||
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<DialogFileUploadError bind:open={showFileErrorDialog} {fileErrorData} />
|
<ChatScreenDialogsAndAlerts
|
||||||
|
{showDeleteDialog}
|
||||||
<DialogConfirmation
|
{handleDeleteConfirm}
|
||||||
bind:open={showDeleteDialog}
|
{showEmptyFileDialog}
|
||||||
title="Delete Conversation"
|
{emptyFileNames}
|
||||||
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
|
{activeErrorDialog}
|
||||||
confirmText="Delete"
|
{handleErrorDialogOpenChange}
|
||||||
cancelText="Cancel"
|
{fileUpload}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,58 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ArrowDown } from '@lucide/svelte';
|
import { ArrowDown } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
|
||||||
|
|
||||||
interface Props {
|
let { onclick }: { onclick: (e?: MouseEvent) => void } = $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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative z-50 mx-auto mb-4 flex max-w-[48rem] justify-center">
|
<div class="pointer-events-auto flex justify-center relative h-0">
|
||||||
<Button
|
<ActionIcon
|
||||||
onclick={scrollToBottom}
|
icon={ArrowDown}
|
||||||
variant="secondary"
|
{onclick}
|
||||||
size="icon"
|
ariaLabel="Scroll to bottom"
|
||||||
disabled={!show}
|
tooltip="Scroll to bottom"
|
||||||
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"
|
size="lg"
|
||||||
style="bottom: {buttonBottom}; transform: translateY({show ? '0' : '2rem'}); opacity: {show
|
iconSize="h-4 w-4"
|
||||||
? 1
|
class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
|
||||||
: 0};"
|
/>
|
||||||
aria-label="Scroll to bottom"
|
|
||||||
>
|
|
||||||
<ArrowDown class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { afterNavigate } from '$app/navigation';
|
import { afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { ChatForm } from '$lib/components/app';
|
import { ChatForm } from '$lib/components/app';
|
||||||
|
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
|
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
|
||||||
|
|
||||||
@@ -32,7 +33,30 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let chatFormRef: ChatForm | undefined = $state(undefined);
|
let chatFormRef: ChatForm | undefined = $state(undefined);
|
||||||
|
let formWrapperEl: HTMLDivElement | undefined = $state();
|
||||||
let chatId = $derived(page.params.id as string | undefined);
|
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 hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
|
||||||
let message = $derived(initialMessage);
|
let message = $derived(initialMessage);
|
||||||
let previousIsLoading = $derived(isLoading);
|
let previousIsLoading = $derived(isLoading);
|
||||||
@@ -83,12 +107,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setTimeout(() => chatFormRef?.focus(), 10);
|
if (!isMobile.current) {
|
||||||
|
setTimeout(() => chatFormRef?.focus(), 100);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate((navigation) => {
|
afterNavigate((navigation) => {
|
||||||
if (navigation?.from != null) {
|
if (navigation?.from != null && !isMobile.current) {
|
||||||
setTimeout(() => chatFormRef?.focus(), 10);
|
setTimeout(() => chatFormRef?.focus(), 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,12 +134,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative mx-auto max-w-[48rem]">
|
<div class="chat-screen-form-wrapper" bind:this={formWrapperEl}>
|
||||||
<ChatForm
|
<ChatForm
|
||||||
|
class="mx-auto max-w-3xl {className}"
|
||||||
bind:this={chatFormRef}
|
bind:this={chatFormRef}
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
bind:uploadedFiles
|
bind:uploadedFiles
|
||||||
class={className}
|
|
||||||
{disabled}
|
{disabled}
|
||||||
{isLoading}
|
{isLoading}
|
||||||
showMcpPromptButton
|
showMcpPromptButton
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
|
||||||
import { serverStore } from '$lib/stores/server.svelte';
|
import { serverStore } from '$lib/stores/server.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,10 +10,9 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'pointer-events-none mb-4 hidden px-4 text-center',
|
'pointer-events-none mb-4 hidden px-4 text-center text-balance',
|
||||||
isEmpty && 'pointer-events-auto block!'
|
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>
|
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,8 @@
|
|||||||
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||||
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
|
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { getProcessingInfoContext } from '$lib/contexts';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
|
|
||||||
const processingState = useProcessingState();
|
const processingState = useProcessingState();
|
||||||
const processingInfoCtx = getProcessingInfoContext();
|
|
||||||
|
|
||||||
let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
|
|
||||||
|
|
||||||
let isCurrentConversationLoading = $derived(isLoading());
|
let isCurrentConversationLoading = $derived(isLoading());
|
||||||
let isStreaming = $derived(isChatStreaming());
|
let isStreaming = $derived(isChatStreaming());
|
||||||
@@ -70,8 +65,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'chat-processing-info-container pointer-events-none relative',
|
'chat-processing-info-container pointer-events-none relative w-full hidden md:block',
|
||||||
page.params.id && showProcessingInfo && 'visible'
|
processingVisible && 'visible'
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div class="chat-processing-info-content absolute bottom-4 left-1/2 -translate-x-1/2">
|
<div class="chat-processing-info-content absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||||
|
|||||||
@@ -677,13 +677,6 @@ export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
|
|||||||
*/
|
*/
|
||||||
export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.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.
|
* Server error alert displayed when the server is unreachable.
|
||||||
* Shows the error message with a retry button.
|
* Shows the error message with a retry button.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Search, X } from '@lucide/svelte';
|
import { Search, X } from '@lucide/svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
autofocus?: boolean;
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onInput?: (value: string) => void;
|
onInput?: (value: string) => void;
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
autofocus,
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
onInput,
|
onInput,
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
if (value) {
|
if (value) {
|
||||||
value = '';
|
value = '';
|
||||||
onInput?.('');
|
onInput?.('');
|
||||||
ref?.focus();
|
ref?.focus({ preventScroll: true });
|
||||||
} else {
|
} else {
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
{autofocus}
|
||||||
{id}
|
{id}
|
||||||
bind:value
|
bind:value
|
||||||
bind:ref
|
bind:ref
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -51,3 +51,11 @@ export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';
|
|||||||
* Preview button is shown only for HTML code blocks.
|
* Preview button is shown only for HTML code blocks.
|
||||||
*/
|
*/
|
||||||
export { default as CodeBlockActions } from './CodeBlockActions.svelte';
|
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';
|
||||||
|
|||||||
@@ -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>
|
|
||||||
+234
-295
@@ -1,40 +1,67 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Trash2, Pencil, Pin, X } from '@lucide/svelte';
|
import { PanelLeftClose, PanelLeftOpen, 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 {
|
import {
|
||||||
conversationsStore,
|
ActionIcon,
|
||||||
conversations,
|
Logo,
|
||||||
buildConversationTree
|
SidebarNavigationConversationList,
|
||||||
} from '$lib/stores/conversations.svelte';
|
SidebarNavigationActions
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
} from '$lib/components/app';
|
||||||
import { getPreviewText } from '$lib/utils';
|
import { ROUTES } from '$lib/constants';
|
||||||
import { APP_NAME } from '$lib/constants';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
const sidebar = Sidebar.useSidebar();
|
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 { 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() });
|
||||||
|
|
||||||
|
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 currentChatId = $derived(page.params.id);
|
||||||
let isSearchModeActive = $state(false);
|
let isSearchModeActive = $state(false);
|
||||||
let searchQuery = $state('');
|
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(() => {
|
let filteredConversations = $derived.by(() => {
|
||||||
if (isSearchModeActive) {
|
if (isSearchModeActive) {
|
||||||
@@ -50,294 +77,206 @@
|
|||||||
return conversations();
|
return conversations();
|
||||||
});
|
});
|
||||||
|
|
||||||
let conversationTree = $derived(buildConversationTree(filteredConversations));
|
async function selectConversation(id: string) {
|
||||||
|
if (isMobile.current) {
|
||||||
let pinnedConversations = $derived.by(() => {
|
scheduleMobileCollapse();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
await goto(RouterService.chat(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditConversation(id: string) {
|
async function handleEditConversation(id: string) {
|
||||||
const conversation = conversations().find((conv) => conv.id === id);
|
const conversation = conversations().find((conv) => conv.id === id);
|
||||||
if (conversation) {
|
if (!conversation) return;
|
||||||
selectedConversation = conversation;
|
|
||||||
editedName = conversation.name;
|
const newName = window.prompt('Rename conversation', conversation.name);
|
||||||
showEditDialog = true;
|
if (newName && newName.trim()) {
|
||||||
|
await conversationsStore.updateConversationName(id, newName.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConfirmDelete() {
|
async function handleDeleteConversation(id: string) {
|
||||||
if (selectedConversation) {
|
const conversation = conversations().find((conv) => conv.id === id);
|
||||||
const convId = selectedConversation.id;
|
if (!conversation) return;
|
||||||
const withForks = deleteWithForks;
|
|
||||||
showDeleteDialog = false;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
const confirmed = window.confirm(
|
||||||
conversationsStore.deleteConversation(convId, {
|
`Delete "${conversation.name}"? This action cannot be undone.`
|
||||||
deleteWithForks: withForks
|
);
|
||||||
});
|
if (!confirmed) return;
|
||||||
}, 100); // Wait for animation to finish
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleConfirmEdit() {
|
await conversationsStore.deleteConversation(id, { deleteWithForks: false });
|
||||||
if (!editedName.trim() || !selectedConversation) return;
|
|
||||||
|
|
||||||
showEditDialog = false;
|
|
||||||
|
|
||||||
conversationsStore.updateConversationName(selectedConversation.id, editedName);
|
|
||||||
selectedConversation = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleMobileSidebarItemClick() {
|
|
||||||
if (sidebar.isMobile) {
|
|
||||||
sidebar.toggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let chatSidebarActions: { activateSearch?: () => void } | undefined = $state();
|
|
||||||
let openedForSearch = $state(false);
|
|
||||||
|
|
||||||
export function activateSearchMode() {
|
|
||||||
if (!sidebar.open) {
|
|
||||||
openedForSearch = true;
|
|
||||||
}
|
|
||||||
chatSidebarActions?.activateSearch?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchDeactivated() {
|
|
||||||
if (openedForSearch) {
|
|
||||||
openedForSearch = false;
|
|
||||||
sidebar.toggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!sidebar.open) {
|
|
||||||
isSearchModeActive = false;
|
|
||||||
searchQuery = '';
|
|
||||||
openedForSearch = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function editActiveConversation() {
|
|
||||||
if (currentChatId) {
|
|
||||||
const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
|
|
||||||
|
|
||||||
if (activeConversation) {
|
|
||||||
const event = new CustomEvent('edit-active-conversation', {
|
|
||||||
detail: { conversationId: currentChatId }
|
|
||||||
});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectConversation(id: string) {
|
|
||||||
if (isSearchModeActive) {
|
|
||||||
isSearchModeActive = false;
|
|
||||||
searchQuery = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMobileSidebarItemClick();
|
|
||||||
await goto(RouterService.chat(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStopGeneration(id: string) {
|
function handleStopGeneration(id: string) {
|
||||||
chatStore.stopGenerationForChat(id);
|
chatStore.stopGenerationForChat(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let innerWidth = $state(0);
|
||||||
|
let pendingCollapse = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
function scheduleMobileCollapse() {
|
||||||
|
if (pendingCollapse) {
|
||||||
|
clearTimeout(pendingCollapse);
|
||||||
|
}
|
||||||
|
pendingCollapse = setTimeout(() => {
|
||||||
|
isExpandedMode = false;
|
||||||
|
pendingCollapse = null;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMobileCollapse() {
|
||||||
|
if (pendingCollapse) {
|
||||||
|
clearTimeout(pendingCollapse);
|
||||||
|
pendingCollapse = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<svelte:window onkeydown={handleKeydown} bind:innerWidth />
|
||||||
<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
|
{#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))}
|
||||||
class="rounded-full md:hidden"
|
<aside
|
||||||
variant="ghost"
|
class={[
|
||||||
size="icon"
|
// Layout & positioning
|
||||||
onclick={() => sidebar.toggle()}
|
'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
|
||||||
<X class="h-4 w-4" />
|
'md:h-[calc(100dvh-1.125rem)]',
|
||||||
<span class="sr-only">Close sidebar</span>
|
isExpandedMode &&
|
||||||
</Button>
|
(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>
|
</div>
|
||||||
|
|
||||||
<SidebarNavigationActions
|
{#if isExpandedMode || isOnMobile}
|
||||||
bind:this={chatSidebarActions}
|
<div
|
||||||
{handleMobileSidebarItemClick}
|
class="flex items-center transition-all duration-150 ease-out {isMobile.current &&
|
||||||
bind:isSearchModeActive
|
!isExpandedMode
|
||||||
bind:searchQuery
|
? 'opacity-0 h-0!'
|
||||||
onSearchDeactivated={handleSearchDeactivated}
|
: ''}"
|
||||||
/>
|
in:fade={{ duration: 150, easing: circIn, delay: 50 }}
|
||||||
</Sidebar.Header>
|
out:fade={{ duration: 100 }}
|
||||||
|
>
|
||||||
{#if !isSearchModeActive && pinnedConversations.length > 0}
|
<ActionIcon
|
||||||
<Sidebar.Group class="p-0 px-4">
|
icon={isMobile.current ? X : PanelLeftClose}
|
||||||
<Sidebar.GroupLabel>
|
size="lg"
|
||||||
<div class="flex items-center gap-1">
|
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
||||||
<Pin class="h-3.5 w-3.5" />
|
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
|
||||||
<span>Pinned</span>
|
onclick={toggleExpandedMode}
|
||||||
</div>
|
tooltip="Close Sidebar"
|
||||||
</Sidebar.GroupLabel>
|
tooltipSide={TooltipSide.LEFT}
|
||||||
<Sidebar.GroupContent>
|
ariaLabel="Collapse navigation"
|
||||||
<Sidebar.Menu>
|
/>
|
||||||
{#each pinnedConversations as { conversation, depth } (conversation.id)}
|
</div>
|
||||||
<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}
|
{/if}
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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} />
|
|
||||||
|
|
||||||
<Label for="delete-with-forks" class="text-sm">Also delete all forked conversations</Label>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</DialogConfirmation>
|
|
||||||
|
|
||||||
<DialogConfirmation
|
<div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 overflow-y-auto">
|
||||||
bind:open={showEditDialog}
|
<div
|
||||||
title="Edit Conversation Name"
|
class="flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {isMobile.current
|
||||||
description=""
|
? 'transition-[opacity,height] duration-200 ease-out'
|
||||||
confirmText="Save"
|
: ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
|
||||||
cancelText="Cancel"
|
in:fade={{ duration: 200 }}
|
||||||
icon={Pencil}
|
out:fade={{ duration: 200 }}
|
||||||
onConfirm={handleConfirmEdit}
|
>
|
||||||
onCancel={() => {
|
<SidebarNavigationActions
|
||||||
showEditDialog = false;
|
isExpandedMode={innerWidth > 768 ? isExpandedMode : true}
|
||||||
selectedConversation = null;
|
class="px-2"
|
||||||
}}
|
bind:isSearchModeActive
|
||||||
onKeydown={(event) => {
|
bind:searchQuery
|
||||||
if (event.key === 'Enter') {
|
onSearchDeactivated={() => {
|
||||||
event.preventDefault();
|
isSearchModeActive = false;
|
||||||
event.stopImmediatePropagation();
|
searchQuery = '';
|
||||||
handleConfirmEdit();
|
}}
|
||||||
|
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}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
aside {
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
--size: 1.125rem;
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
<Input
|
@media (max-width: 768px) {
|
||||||
class="text-foreground"
|
aside {
|
||||||
placeholder="Enter a new name"
|
&:not(.is-expanded) {
|
||||||
type="text"
|
pointer-events: none;
|
||||||
bind:value={editedName}
|
}
|
||||||
/>
|
}
|
||||||
</DialogConfirmation>
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+157
-57
@@ -1,39 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { KeyboardShortcutInfo } from '$lib/components/app';
|
import { goto } from '$app/navigation';
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import type { Component } from 'svelte';
|
|
||||||
import { SearchInput } from '$lib/components/app';
|
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { SIDEBAR_ACTIONS_ITEMS } from '$lib/constants/ui';
|
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';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
handleMobileSidebarItemClick: () => void;
|
class: string;
|
||||||
|
isExpandedMode: boolean;
|
||||||
isSearchModeActive: boolean;
|
isSearchModeActive: boolean;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
isCancelAlwaysVisible?: boolean;
|
|
||||||
onSearchDeactivated?: () => void;
|
onSearchDeactivated?: () => void;
|
||||||
|
onSearchClick?: () => void;
|
||||||
|
onNewChat?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
handleMobileSidebarItemClick,
|
class: className,
|
||||||
isSearchModeActive = $bindable(),
|
isExpandedMode = false,
|
||||||
searchQuery = $bindable(),
|
isSearchModeActive = $bindable(false),
|
||||||
isCancelAlwaysVisible = false,
|
searchQuery = $bindable(''),
|
||||||
onSearchDeactivated
|
onSearchDeactivated,
|
||||||
|
onSearchClick,
|
||||||
|
onNewChat
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let initialized = $state(false);
|
||||||
|
let showIcons = $state(false);
|
||||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
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() {
|
function handleSearchModeDeactivate() {
|
||||||
isSearchModeActive = false;
|
isSearchModeActive = false;
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
onSearchDeactivated?.();
|
onSearchDeactivated?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateSearch() {
|
function isItemActive(item: {
|
||||||
isSearchModeActive = true;
|
activeRouteId?: string;
|
||||||
// Focus after Svelte renders the input
|
activeRoutePrefix?: string;
|
||||||
queueMicrotask(() => searchInputRef?.focus());
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -41,56 +88,109 @@
|
|||||||
<IconComponent class="h-4 w-4" />
|
<IconComponent class="h-4 w-4" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="my-1 space-y-1">
|
{#if isSearchModeActive}
|
||||||
{#if isSearchModeActive}
|
<div class="px-4 my-2">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
bind:ref={searchInputRef}
|
bind:ref={searchInputRef}
|
||||||
onClose={handleSearchModeDeactivate}
|
onClose={handleSearchModeDeactivate}
|
||||||
onKeyDown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
|
onKeyDown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
|
||||||
placeholder="Search conversations..."
|
placeholder="Search conversations..."
|
||||||
{isCancelAlwaysVisible}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
</div>
|
||||||
{#each SIDEBAR_ACTIONS_ITEMS as item (item.route)}
|
{:else if isExpandedMode || isOnMobile}
|
||||||
{#if !item.route}
|
<div
|
||||||
<Button
|
class="{className} flex flex-col gap-5 md:gap-1 mt-2 md:mt-0 {!isExpandedMode && isOnMobile
|
||||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
? 'hidden pointer-events-none'
|
||||||
onclick={activateSearch}
|
: ''}"
|
||||||
variant="ghost"
|
>
|
||||||
>
|
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
||||||
<div class="flex items-center gap-2">
|
{@const isActive = isItemActive(item)}
|
||||||
{@render itemIcon(item.icon)}
|
{@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
|
||||||
|
}}
|
||||||
|
|
||||||
{item.tooltip}
|
{#if showIcons}
|
||||||
</div>
|
<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 item.keys}
|
{#if showIcons}
|
||||||
<KeyboardShortcutInfo keys={item.keys} />
|
<span
|
||||||
{/if}
|
in:fade={{ duration: 150, easing: circIn, delay: 50 }}
|
||||||
</Button>
|
out:fade={{ duration: 100 }}
|
||||||
{:else}
|
class="min-w-0 truncate">{item.tooltip}</span
|
||||||
<Button
|
>
|
||||||
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {(item.activeRouteId &&
|
{/if}
|
||||||
page.route.id === item.activeRouteId) ||
|
</span>
|
||||||
(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}
|
{#if item.keys}
|
||||||
</div>
|
<KeyboardShortcutInfo keys={item.keys} />
|
||||||
|
{/if}
|
||||||
{#if item.keys}
|
</Button>
|
||||||
<KeyboardShortcutInfo keys={item.keys} />
|
</div>
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
+135
@@ -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}
|
||||||
+3
-1
@@ -16,4 +16,6 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
|
<div class="mb-4 px-2 {className}">
|
||||||
|
<SearchInput bind:value {placeholder} {onInput} />
|
||||||
|
</div>
|
||||||
|
|||||||
+76
@@ -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>
|
||||||
@@ -63,15 +63,6 @@ export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svel
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
|
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
|
* **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';
|
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),
|
* Single conversation item in sidebar. Displays conversation title (truncated),
|
||||||
* last message preview, and timestamp. Shows context menu on right-click with
|
* 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';
|
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
|
* Search input for filtering conversations in sidebar. Filters conversation
|
||||||
* list by title as user types. Shows clear button when query is not empty.
|
* list by title as user types. Shows clear button when query is not empty.
|
||||||
|
|||||||
@@ -126,10 +126,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="mx-auto flex h-full w-full flex-col md:pl-8" in:fade={{ duration: 150 }}>
|
||||||
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="flex flex-1 flex-col gap-4 md:flex-row">
|
<div class="flex flex-1 flex-col gap-4 md:flex-row">
|
||||||
<SettingsChatDesktopSidebar
|
<SettingsChatDesktopSidebar
|
||||||
sections={SETTINGS_CHAT_SECTIONS}
|
sections={SETTINGS_CHAT_SECTIONS}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
let { sections, isActive, getHref, onSectionChange }: Props = $props();
|
let { sections, isActive, getHref, onSectionChange }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-10 pb-4 md:flex">
|
<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 pb-10">
|
<div class="flex items-center gap-2 py-2">
|
||||||
<Settings class="h-6 w-6" />
|
<Settings class="h-5 w-5 md:h-6 md:w-6" />
|
||||||
<h1 class="text-2xl font-semibold">Settings</h1>
|
|
||||||
|
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="space-y-1">
|
<nav class="space-y-1">
|
||||||
{#each sections as section (section.title)}
|
{#each sections as section (section.title)}
|
||||||
{#if getHref}
|
{#if getHref}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus } from '@lucide/svelte';
|
import { X, Plus } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { toolsStore } from '$lib/stores/tools.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 { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
|
import { ROUTES } from '$lib/constants';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import McpLogo from '../mcp/McpLogo.svelte';
|
import McpLogo from '../mcp/McpLogo.svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { replaceState } from '$app/navigation';
|
import { goto, replaceState } from '$app/navigation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -24,6 +26,24 @@
|
|||||||
let initialLoadComplete = $state(false);
|
let initialLoadComplete = $state(false);
|
||||||
let isAddingServer = $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(() => {
|
onMount(() => {
|
||||||
if (page.url.searchParams.has('add')) {
|
if (page.url.searchParams.has('add')) {
|
||||||
isAddingServer = true;
|
isAddingServer = true;
|
||||||
@@ -54,15 +74,26 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fade={{ duration: 150 }} class="h-full max-h-[100dvh] overflow-y-auto">
|
<div in:fade={{ duration: 150 }}>
|
||||||
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
|
<div class="fixed top-4.5 right-4 z-50 md:hidden">
|
||||||
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
|
<ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sticky top-0 z-10 mt-4 flex items-start gap-4 p-4 md:justify-end md:px-8">
|
<div
|
||||||
<Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
|
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" />
|
<Plus class="h-4 w-4" />
|
||||||
|
|
||||||
Add New Server
|
Add New Server
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
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',
|
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-lg': 'size-10',
|
||||||
icon: 'size-9',
|
icon: 'size-9',
|
||||||
'icon-sm': 'size-5 rounded-sm'
|
'icon-sm': 'size-5 rounded-sm'
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
@@ -7,7 +7,8 @@ import { APP_NAME } from './app';
|
|||||||
|
|
||||||
export const MEDIA_QUERIES = {
|
export const MEDIA_QUERIES = {
|
||||||
PREFERS_DARK: '(prefers-color-scheme: dark)',
|
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;
|
} as const;
|
||||||
|
|
||||||
export const THEME_COLORS = {
|
export const THEME_COLORS = {
|
||||||
@@ -34,6 +35,13 @@ export const FAVICON_PATHS = {
|
|||||||
SVG_DARK: 'favicon-dark.svg'
|
SVG_DARK: 'favicon-dark.svg'
|
||||||
} as const;
|
} 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 = {
|
export const FAVICON_SELECTORS = {
|
||||||
ICO_48X48: 'link[rel="icon"][sizes="48x48"]',
|
ICO_48X48: 'link[rel="icon"][sizes="48x48"]',
|
||||||
SVG_ANY: 'link[rel="icon"][type="image/svg+xml"]'
|
SVG_ANY: 'link[rel="icon"][type="image/svg+xml"]'
|
||||||
@@ -222,8 +230,13 @@ export const PWA_GENERATOR_DEVICES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// PWA assets generator configuration — used by pwa-assets.config.ts
|
// 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 = {
|
export const PWA_ASSET_GENERATOR = {
|
||||||
LINK_PRESET: '2023',
|
LINK_PRESET: '2023',
|
||||||
|
FAVICON_PADDING: 0.04,
|
||||||
SPLASH_PADDING: 0.75,
|
SPLASH_PADDING: 0.75,
|
||||||
FIT_MODE: 'contain',
|
FIT_MODE: 'contain',
|
||||||
ADD_MEDIA_SCREEN: true,
|
ADD_MEDIA_SCREEN: true,
|
||||||
|
|||||||
@@ -23,5 +23,7 @@ export const ROUTES = {
|
|||||||
/** MCP servers. */
|
/** MCP servers. */
|
||||||
MCP_SERVERS: '#/mcp-servers',
|
MCP_SERVERS: '#/mcp-servers',
|
||||||
/** Settings base — for dynamic settings URLs use RouterService. */
|
/** Settings base — for dynamic settings URLs use RouterService. */
|
||||||
SETTINGS: '#/settings'
|
SETTINGS: '#/settings',
|
||||||
|
/** Search — mobile-only full-page conversation search. */
|
||||||
|
SEARCH: '#/search'
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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 McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import { ROUTES } from './routes';
|
import { ROUTES } from './routes';
|
||||||
@@ -15,6 +15,7 @@ export interface DesktopIconStripItem {
|
|||||||
route?: string;
|
route?: string;
|
||||||
activeRouteId?: string;
|
activeRouteId?: string;
|
||||||
activeRoutePrefix?: string;
|
activeRoutePrefix?: string;
|
||||||
|
activeUrlIncludes?: string;
|
||||||
keys?: string[];
|
keys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
|||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
route: ROUTES.SETTINGS,
|
route: `${ROUTES.SETTINGS}/general`,
|
||||||
activeRoutePrefix: '/settings'
|
activeUrlIncludes: '#/settings'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export class AutoScrollController {
|
|||||||
*/
|
*/
|
||||||
resetScrollState(): void {
|
resetScrollState(): void {
|
||||||
this._userScrolledUp = false;
|
this._userScrolledUp = false;
|
||||||
this._autoScrollEnabled = true;
|
this._autoScrollEnabled = !this._disabled;
|
||||||
|
if (this._container) {
|
||||||
|
this._lastScrollTop = this._container.scrollTop;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
|||||||
'[data-slot="chat-form"] textarea'
|
'[data-slot="chat-form"] textarea'
|
||||||
);
|
);
|
||||||
|
|
||||||
textarea?.focus();
|
textarea?.focus({ preventScroll: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -576,7 +576,7 @@ class ModelsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try loading a favorite model
|
// Try loading a favorite model
|
||||||
const favorite = this.favoriteModelIds.values().next()?.value
|
const favorite = this.favoriteModelIds.values().next()?.value;
|
||||||
if (favorite) {
|
if (favorite) {
|
||||||
await this.selectModelById(favorite);
|
await this.selectModelById(favorite);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,18 +6,12 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
import {
|
import { SidebarNavigation, DialogConversationTitleUpdate } from '$lib/components/app';
|
||||||
DesktopIconStrip,
|
|
||||||
DialogConversationTitleUpdate,
|
|
||||||
SidebarNavigation
|
|
||||||
} from '$lib/components/app';
|
|
||||||
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
|
import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
|
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
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 * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
|
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||||
@@ -31,7 +25,6 @@
|
|||||||
import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa';
|
import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa';
|
||||||
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||||
import { usePwa } from '$lib/hooks/use-pwa.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 { conversations } from '$lib/stores/conversations.svelte';
|
||||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
import { theme } from '$lib/stores/theme.svelte';
|
import { theme } from '$lib/stores/theme.svelte';
|
||||||
@@ -42,8 +35,6 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
|
let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
|
||||||
let isDesktop = $derived(!isMobile.current);
|
let isDesktop = $derived(!isMobile.current);
|
||||||
let sidebarOpen = $state(false);
|
|
||||||
let mounted = $state(false);
|
|
||||||
let innerHeight = $state<number | undefined>();
|
let innerHeight = $state<number | undefined>();
|
||||||
let innerWidth = $state(browser ? window.innerWidth : 0);
|
let innerWidth = $state(browser ? window.innerWidth : 0);
|
||||||
|
|
||||||
@@ -61,7 +52,6 @@
|
|||||||
let titleUpdateNewTitle = $state('');
|
let titleUpdateNewTitle = $state('');
|
||||||
let titleUpdateResolve: ((value: boolean) => void) | null = null;
|
let titleUpdateResolve: ((value: boolean) => void) | null = null;
|
||||||
|
|
||||||
const panelNav = useSettingsNavigation();
|
|
||||||
// Keep the hook object intact: destructuring needRefreshByStorage reads the getter once and freezes it
|
// Keep the hook object intact: destructuring needRefreshByStorage reads the getter once and freezes it
|
||||||
const pwa = usePwa();
|
const pwa = usePwa();
|
||||||
const { needRefresh, updateServiceWorker } = pwa;
|
const { needRefresh, updateServiceWorker } = pwa;
|
||||||
@@ -166,7 +156,6 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
updateFavicon();
|
updateFavicon();
|
||||||
mounted = true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -177,8 +166,6 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (alwaysShowSidebarOnDesktop && isDesktop) {
|
if (alwaysShowSidebarOnDesktop && isDesktop) {
|
||||||
sidebarOpen = true;
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -300,19 +287,25 @@
|
|||||||
<PwaMetaTags />
|
<PwaMetaTags />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- PWA update prompt + version -->
|
<svelte:window onkeydown={handleKeydown} bind:innerHeight bind:innerWidth />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
|
<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 />
|
<ModeWatcher />
|
||||||
|
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
@@ -324,44 +317,17 @@
|
|||||||
onConfirm={handleTitleUpdateConfirm}
|
onConfirm={handleTitleUpdateConfirm}
|
||||||
onCancel={handleTitleUpdateCancel}
|
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>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -27,12 +27,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-full">
|
<div class="fixed top-4.5 right-4 z-50 md:hidden">
|
||||||
<div class="fixed top-4.5 right-4 z-50 md:hidden">
|
<ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
|
||||||
<ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="min-h-full">
|
||||||
<div class="min-h-full">
|
{@render children?.()}
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SettingsChat } from '$lib/components/app/settings';
|
import { SettingsChat } from '$lib/components/app/settings';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { replaceState } from '$app/navigation';
|
import { afterNavigate, replaceState } from '$app/navigation';
|
||||||
import { RouterService } from '$lib/services';
|
import { RouterService } from '$lib/services';
|
||||||
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
|
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
|
|
||||||
onMount(() => {
|
afterNavigate(() => {
|
||||||
if (!page.params.section) {
|
if (!page.params.section) {
|
||||||
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
|
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 868 B |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 868 B |
@@ -1,9 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
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';
|
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||||
|
|
||||||
let sidebarOpen = $state(false);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -11,7 +8,5 @@
|
|||||||
This mirrors the providers from +layout.svelte.
|
This mirrors the providers from +layout.svelte.
|
||||||
-->
|
-->
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
<Page />
|
||||||
<Page />
|
|
||||||
</Sidebar.Provider>
|
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|||||||
@@ -14,10 +14,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
|
||||||
|
|
||||||
let sidebarOpen = $state(true);
|
|
||||||
|
|
||||||
// Mock conversations for the sidebar
|
// Mock conversations for the sidebar
|
||||||
const mockConversations: DatabaseConversation[] = [
|
const mockConversations: DatabaseConversation[] = [
|
||||||
{
|
{
|
||||||
@@ -66,11 +62,9 @@
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
<div class="flex-column h-screen w-72 bg-background">
|
||||||
<div class="flex-column h-full h-screen w-72 bg-background">
|
<SidebarNavigation />
|
||||||
<SidebarNavigation />
|
</div>
|
||||||
</div>
|
|
||||||
</Sidebar.Provider>
|
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
@@ -85,15 +79,16 @@
|
|||||||
}, 0)
|
}, 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');
|
const searchTrigger = screen.getByText('Search');
|
||||||
userEvent.click(searchTrigger);
|
userEvent.click(searchTrigger);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
<div class="flex-column h-screen w-72 bg-background">
|
||||||
<div class="flex-column h-full h-screen w-72 bg-background">
|
<SidebarNavigation />
|
||||||
<SidebarNavigation />
|
</div>
|
||||||
</div>
|
|
||||||
</Sidebar.Provider>
|
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
@@ -105,9 +100,7 @@
|
|||||||
conversationsStore.conversations = [];
|
conversationsStore.conversations = [];
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Sidebar.Provider bind:open={sidebarOpen}>
|
<div class="flex-column h-screen w-72 bg-background">
|
||||||
<div class="flex-column h-full h-screen w-72 bg-background">
|
<SidebarNavigation />
|
||||||
<SidebarNavigation />
|
</div>
|
||||||
</div>
|
|
||||||
</Sidebar.Provider>
|
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user