* 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>
283 lines
7.9 KiB
Svelte
283 lines
7.9 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte';
|
|
import {
|
|
ActionIcon,
|
|
Logo,
|
|
SidebarNavigationConversationList,
|
|
SidebarNavigationActions
|
|
} from '$lib/components/app';
|
|
import { ROUTES } from '$lib/constants';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
|
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
|
import { chatStore } from '$lib/stores/chat.svelte';
|
|
import { 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 isSearchModeActive = $state(false);
|
|
let searchQuery = $state('');
|
|
|
|
let filteredConversations = $derived.by(() => {
|
|
if (isSearchModeActive) {
|
|
if (searchQuery.trim().length > 0) {
|
|
return conversations().filter((conversation: { name: string }) =>
|
|
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
return conversations();
|
|
});
|
|
|
|
async function selectConversation(id: string) {
|
|
if (isMobile.current) {
|
|
scheduleMobileCollapse();
|
|
}
|
|
await goto(RouterService.chat(id));
|
|
}
|
|
|
|
async function handleEditConversation(id: string) {
|
|
const conversation = conversations().find((conv) => conv.id === id);
|
|
if (!conversation) 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((conv) => conv.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);
|
|
}
|
|
|
|
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>
|
|
|
|
<svelte:window onkeydown={handleKeydown} bind:innerWidth />
|
|
|
|
{#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))}
|
|
<aside
|
|
class={[
|
|
// Layout & positioning
|
|
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
|
|
// Dimensions & overflow
|
|
'md:h-[calc(100dvh-1.125rem)]',
|
|
isExpandedMode &&
|
|
(device.isStandalone
|
|
? 'h-[calc(100dvh-2rem)]'
|
|
: device.isIOSDevice
|
|
? 'h-[calc(100dvh-0.5rem)]'
|
|
: 'h-[calc(100dvh-1rem)]'),
|
|
// Shape & depth
|
|
'rounded-3xl md:rounded-2xl',
|
|
// Flex layout
|
|
'flex flex-col justify-between',
|
|
// Transition
|
|
'md:transition-[width,padding] duration-200 ease-out',
|
|
// Expanded state: width, surface, depth
|
|
isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl border-border shadow-md',
|
|
// Collapsed state
|
|
!isStripExpanded && 'md:w-12',
|
|
// Expanded mode flag (for mobile ::before overlay)
|
|
isExpandedMode && 'is-expanded'
|
|
]}
|
|
>
|
|
<div class="px-2 flex items-center justify-between">
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
class="relative"
|
|
onmouseenter={() => (logoHovered = true)}
|
|
onmouseleave={() => (logoHovered = false)}
|
|
>
|
|
<ActionIcon
|
|
icon={!isExpandedMode && logoHovered && innerWidth > 768 ? PanelLeftOpen : Logo}
|
|
size="lg"
|
|
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
|
class="{isExpandedMode
|
|
? 'bg-muted! md:bg-foreground/5!'
|
|
: 'bg-transparent!'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
|
|
href={isExpandedMode ? ROUTES.START : undefined}
|
|
onclick={isExpandedMode ? undefined : toggleExpandedMode}
|
|
tooltip={isExpandedMode ? undefined : 'Open Sidebar'}
|
|
tooltipSide={TooltipSide.RIGHT}
|
|
ariaLabel={isExpandedMode ? 'Go to start' : 'Expand navigation'}
|
|
/>
|
|
</div>
|
|
|
|
{#if isExpandedMode || isOnMobile}
|
|
<div
|
|
class="flex items-center transition-all duration-150 ease-out {isMobile.current &&
|
|
!isExpandedMode
|
|
? 'opacity-0 h-0!'
|
|
: ''}"
|
|
in:fade={{ duration: 150, easing: circIn, delay: 50 }}
|
|
out:fade={{ duration: 100 }}
|
|
>
|
|
<ActionIcon
|
|
icon={isMobile.current ? X : PanelLeftClose}
|
|
size="lg"
|
|
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
|
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
|
|
onclick={toggleExpandedMode}
|
|
tooltip="Close Sidebar"
|
|
tooltipSide={TooltipSide.LEFT}
|
|
ariaLabel="Collapse navigation"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 overflow-y-auto">
|
|
<div
|
|
class="flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {isMobile.current
|
|
? 'transition-[opacity,height] duration-200 ease-out'
|
|
: ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
|
|
in:fade={{ duration: 200 }}
|
|
out:fade={{ duration: 200 }}
|
|
>
|
|
<SidebarNavigationActions
|
|
isExpandedMode={innerWidth > 768 ? isExpandedMode : true}
|
|
class="px-2"
|
|
bind:isSearchModeActive
|
|
bind:searchQuery
|
|
onSearchDeactivated={() => {
|
|
isSearchModeActive = false;
|
|
searchQuery = '';
|
|
}}
|
|
onSearchClick={() => {
|
|
isExpandedMode = true;
|
|
isSearchModeActive = true;
|
|
}}
|
|
onNewChat={() => {
|
|
if (isMobile.current) {
|
|
scheduleMobileCollapse();
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{#if isExpandedMode || isOnMobile}
|
|
<SidebarNavigationConversationList
|
|
class="px-2"
|
|
{filteredConversations}
|
|
{currentChatId}
|
|
{isSearchModeActive}
|
|
{searchQuery}
|
|
onSelect={selectConversation}
|
|
onEdit={handleEditConversation}
|
|
onDelete={handleDeleteConversation}
|
|
onStop={handleStopGeneration}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
{/if}
|
|
|
|
<style>
|
|
aside {
|
|
@media (max-width: 768px) {
|
|
--size: 1.125rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
aside {
|
|
&:not(.is-expanded) {
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
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>
|