Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 156 additions & 18 deletions src/lib/components/MobileNav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
export function closeMobileNav() {
isOpen = false;
}

export function openMobileNav() {
isOpen = true;
}
</script>

<script lang="ts">
import { browser } from "$app/environment";
import { beforeNavigate } from "$app/navigation";
import { onMount, onDestroy } from "svelte";
import { base } from "$app/paths";
import { page } from "$app/state";
import IconNew from "$lib/components/icons/IconNew.svelte";
Expand All @@ -18,6 +23,7 @@
import { shareModal } from "$lib/stores/shareModal";
import { loading } from "$lib/stores/loading";
import { requireAuthUser } from "$lib/utils/auth";

interface Props {
title: string | undefined;
children?: import("svelte").Snippet;
Expand All @@ -39,16 +45,6 @@
// Define the width for the drawer (less than 100% to create the gap)
const drawerWidthPercentage = 85;

const tween = Spring.of(
() => {
if (isOpen) {
return 0 as number;
}
return -100 as number;
},
{ stiffness: 0.2, damping: 0.8 }
);

$effect(() => {
title ??= "New Chat";
});
Expand All @@ -72,6 +68,149 @@
function closeDrawer() {
isOpen = false;
}

// Swipe gesture support for opening/closing the nav with live feedback
// Thresholds from vaul drawer library
const VELOCITY_THRESHOLD = 0.4; // px/ms - if exceeded, snap in swipe direction
const CLOSE_THRESHOLD = 0.25; // 25% position threshold
const DIRECTION_LOCK_THRESHOLD = 10; // px - movement needed to lock direction

let touchstart: Touch | null = null;
let dragStartTime: number = 0;
let isDragging = $state(false);
let dragOffset = $state(-100); // percentage: -100 (closed) to 0 (open)
let dragStartedOpen = false;

// Direction lock: null = undecided, 'horizontal' = drawer drag, 'vertical' = scroll
let directionLock: "horizontal" | "vertical" | null = null;
let potentialDrag = false;

// Spring target: follows dragOffset during drag, follows isOpen after drag ends
const springTarget = $derived(isDragging ? dragOffset : isOpen ? 0 : -100);
const tween = Spring.of(() => springTarget, { stiffness: 0.2, damping: 0.8 });

function onTouchStart(e: TouchEvent) {
const touch = e.changedTouches[0];
touchstart = touch;
dragStartTime = Date.now();
directionLock = null;

const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);
const touchOnDrawer = isOpen && touch.clientX < drawerWidth;

// Potential drag scenarios - never start isDragging until direction is locked
// Exception: overlay tap (no scroll content, so no direction conflict)
if (!isOpen && touch.clientX < 40) {
// Opening gesture - wait for direction lock before starting drag
potentialDrag = true;
dragStartedOpen = false;
} else if (isOpen && !touchOnDrawer) {
// Touch on overlay - can start immediately (no scroll conflict)
potentialDrag = true;
isDragging = true;
dragStartedOpen = true;
dragOffset = 0;
directionLock = "horizontal";
} else if (isOpen && touchOnDrawer) {
// Touch on drawer content - wait for direction lock
potentialDrag = true;
dragStartedOpen = true;
}
}

function onTouchMove(e: TouchEvent) {
if (!touchstart || !potentialDrag) return;

const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchstart.clientX;
const deltaY = touch.clientY - touchstart.clientY;

// Determine direction lock if not yet decided
if (directionLock === null) {
const absX = Math.abs(deltaX);
const absY = Math.abs(deltaY);

if (absX > DIRECTION_LOCK_THRESHOLD || absY > DIRECTION_LOCK_THRESHOLD) {
if (absX > absY) {
// Horizontal movement - commit to drawer drag
directionLock = "horizontal";
isDragging = true;
dragOffset = dragStartedOpen ? 0 : -100;
} else {
// Vertical movement - abort potential drag, let content scroll
directionLock = "vertical";
potentialDrag = false;
return;
}
} else {
return;
}
}

if (directionLock !== "horizontal") return;

const drawerWidth = window.innerWidth * (drawerWidthPercentage / 100);

if (dragStartedOpen) {
dragOffset = Math.max(-100, Math.min(0, (deltaX / drawerWidth) * 100));
} else {
dragOffset = Math.max(-100, Math.min(0, -100 + (deltaX / drawerWidth) * 100));
}
}

function onTouchEnd(e: TouchEvent) {
if (!potentialDrag) return;

if (!isDragging || !touchstart) {
resetDragState();
return;
}

const touch = e.changedTouches[0];
const timeTaken = Date.now() - dragStartTime;
const distMoved = touch.clientX - touchstart.clientX;
const velocity = Math.abs(distMoved) / timeTaken;

// Determine snap direction based on velocity first, then position
if (velocity > VELOCITY_THRESHOLD) {
isOpen = distMoved > 0;
} else {
const openThreshold = -100 + CLOSE_THRESHOLD * 100;
isOpen = dragOffset > openThreshold;
}

resetDragState();
}

function onTouchCancel() {
if (isDragging) {
isOpen = dragStartedOpen;
}
resetDragState();
}

function resetDragState() {
isDragging = false;
potentialDrag = false;
touchstart = null;
directionLock = null;
}

onMount(() => {
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("touchend", onTouchEnd, { passive: true });
window.addEventListener("touchcancel", onTouchCancel, { passive: true });
});

onDestroy(() => {
if (browser) {
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
window.removeEventListener("touchcancel", onTouchCancel);
}
});
</script>

<nav
Expand Down Expand Up @@ -120,23 +259,22 @@
</div>
</nav>

<!-- Mobile drawer overlay - shows when drawer is open -->
{#if isOpen}
<!-- Mobile drawer overlay - shows when drawer is open or dragging -->
{#if isOpen || isDragging}
<button
type="button"
class="fixed inset-0 z-20 cursor-default bg-black/30 md:hidden"
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))};"
style="opacity: {Math.max(0, Math.min(1, (100 + tween.current) / 100))}; will-change: opacity;"
onclick={closeDrawer}
aria-label="Close mobile navigation"
></button>
{/if}

<nav
style="transform: translateX({Math.max(
-100,
Math.min(0, tween.current)
)}%); width: {drawerWidthPercentage}%;"
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen}
style="transform: translateX({isDragging
? dragOffset
: tween.current}%); width: {drawerWidthPercentage}%; will-change: transform;"
class:shadow-[5px_0_15px_0_rgba(0,0,0,0.3)]={isOpen || isDragging}
class="fixed bottom-0 left-0 top-0 z-30 grid max-h-dvh grid-cols-1
grid-rows-[auto,1fr,auto,auto] rounded-r-xl bg-white pt-4 dark:bg-gray-900 md:hidden"
>
Expand Down
20 changes: 10 additions & 10 deletions src/lib/components/chat/ChatInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { afterNavigate } from "$app/navigation";

import { DropdownMenu } from "bits-ui";
import CarbonAdd from "~icons/carbon/add";
import IconPlus from "~icons/lucide/plus";
import CarbonImage from "~icons/carbon/image";
import CarbonDocument from "~icons/carbon/document";
import CarbonUpload from "~icons/carbon/upload";
Expand Down Expand Up @@ -269,11 +269,11 @@
}}
>
<DropdownMenu.Trigger
class="btn size-7 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600"
class="btn size-8 rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600 sm:size-7"
disabled={loading}
aria-label="Add attachment"
>
<CarbonAdd class="text-base" />
<IconPlus class="text-base sm:text-sm" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
Expand All @@ -287,7 +287,7 @@
>
{#if modelIsMultimodal}
<DropdownMenu.Item
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => openFilePickerImage()}
>
<CarbonImage class="size-4 opacity-90 dark:opacity-80" />
Expand All @@ -297,7 +297,7 @@

<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10"
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8"
>
<div class="flex items-center gap-1">
<CarbonDocument class="size-4 opacity-90 dark:opacity-80" />
Expand All @@ -315,14 +315,14 @@
interactOutsideBehavior="defer-otherwise-close"
>
<DropdownMenu.Item
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => openFilePickerText()}
>
<CarbonUpload class="size-4 opacity-90 dark:opacity-80" />
Upload from device
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => (isUrlModalOpen = true)}
>
<CarbonLink class="size-4 opacity-90 dark:opacity-80" />
Expand All @@ -334,7 +334,7 @@
<!-- MCP Servers submenu -->
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10"
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 data-[state=open]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 dark:data-[state=open]:bg-white/10 sm:h-8"
>
<div class="flex items-center gap-1">
<IconMCP classNames="size-4 opacity-90 dark:opacity-80" />
Expand Down Expand Up @@ -389,7 +389,7 @@
<DropdownMenu.Separator class="my-1 h-px bg-gray-200 dark:bg-gray-700/60" />
{/if}
<DropdownMenu.Item
class="flex h-8 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10"
class="flex h-9 select-none items-center gap-1 rounded-md px-2 text-sm text-gray-700 data-[highlighted]:bg-gray-100 focus-visible:outline-none dark:text-gray-200 dark:data-[highlighted]:bg-white/10 sm:h-8"
onSelect={() => (isMcpManagerOpen = true)}
>
Manage MCP Servers
Expand All @@ -402,7 +402,7 @@

{#if $enabledServersCount > 0}
<div
class="ml-2 inline-flex h-7 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400"
class="ml-2 inline-flex h-8 items-center gap-1.5 rounded-full border border-blue-500/10 bg-blue-600/10 pl-2 pr-1 text-xs font-semibold text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 sm:h-7"
class:grayscale={!modelSupportsTools}
class:opacity-60={!modelSupportsTools}
class:cursor-help={!modelSupportsTools}
Expand Down
20 changes: 4 additions & 16 deletions src/lib/components/chat/ChatWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import IconOmni from "$lib/components/icons/IconOmni.svelte";
import CarbonCaretDown from "~icons/carbon/caret-down";
import CarbonDirectionRight from "~icons/carbon/direction-right-01";
import IconArrowUp from "~icons/lucide/arrow-up";

import ChatInput from "./ChatInput.svelte";
import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
Expand Down Expand Up @@ -559,11 +560,11 @@
<StopGeneratingBtn
onClick={() => onstop?.()}
showBorder={true}
classNames="absolute bottom-2 right-2 size-7 self-end rounded-full border bg-white text-black shadow transition-none dark:border-transparent dark:bg-gray-600 dark:text-white"
classNames="absolute bottom-2 right-2 size-8 sm:size-7 self-end rounded-full border bg-white text-black shadow transition-none dark:border-transparent dark:bg-gray-600 dark:text-white"
/>
{:else}
<button
class="btn absolute bottom-2 right-2 size-7 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black {!draft ||
class="btn absolute bottom-2 right-2 size-8 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black sm:size-7 {!draft ||
isReadOnly
? ''
: '!bg-black !text-white dark:!bg-white dark:!text-black'}"
Expand All @@ -572,20 +573,7 @@
aria-label="Send message"
name="submit"
>
<svg
width="1em"
height="1em"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.0606 4.23197C16.4748 3.64618 15.525 3.64618 14.9393 4.23197L5.68412 13.4871C5.09833 14.0729 5.09833 15.0226 5.68412 15.6084C6.2699 16.1942 7.21965 16.1942 7.80544 15.6084L14.4999 8.91395V26.7074C14.4999 27.5359 15.1715 28.2074 15.9999 28.2074C16.8283 28.2074 17.4999 27.5359 17.4999 26.7074V8.91395L24.1944 15.6084C24.7802 16.1942 25.7299 16.1942 26.3157 15.6084C26.9015 15.0226 26.9015 14.0729 26.3157 13.4871L17.0606 4.23197Z"
fill="currentColor"
/>
</svg>
<IconArrowUp />
</button>
{/if}
</div>
Expand Down
Loading
Loading