This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-23 04:45:27 -04:00
1 parent bb12b1a18b
commit d0f7da4f45
11 files changed
+343 -110

No files matched your search

@@ -0,0 +1,149 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import { LinkPreview } from 'bits-ui';
import { getChatById } from '$lib/apis/chats';
import Messages from '$lib/components/chat/Messages.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { user } from '$lib/stores';
import { createMessagesList } from '$lib/utils';
const i18n: any = getContext('i18n');
export let chatId = '';
export let title = '';
export let openPreview = false;
export let side: 'left' | 'right' | 'bottom' | 'top' = 'right';
export let align: 'start' | 'end' | 'center' = 'start';
export let sideOffset = 12;
let messagesContainerElement: HTMLElement | null = null;
let selectedModels = [''];
let history: any = null;
let previewMessages: any[] = [];
let previewReady = false;
let loading = false;
let error = '';
let requestedChatId = '';
let loadToken = 0;
const scrollPreviewToBottom = async () => {
await tick();
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
requestAnimationFrame(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
});
}
});
setTimeout(() => {
if (messagesContainerElement) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}, 80);
};
const loadChatPreview = async (id: string) => {
if (!id || loading || requestedChatId === id) return;
const token = ++loadToken;
requestedChatId = id;
loading = true;
error = '';
previewReady = false;
previewMessages = [];
history = null;
selectedModels = [''];
const chat = await getChatById(localStorage.token, id).catch(() => null);
if (token !== loadToken) return;
if (chat?.chat?.history) {
selectedModels = Array.isArray(chat.chat.models)
? chat.chat.models
: [chat.chat.models ?? ''];
history = chat.chat.history;
previewMessages = createMessagesList(chat.chat.history, chat.chat.history.currentId);
previewReady = true;
scrollPreviewToBottom();
} else if (chat) {
previewMessages = [];
previewReady = true;
} else {
error = $i18n.t('Failed to load chat preview');
}
loading = false;
};
$: if (openPreview && chatId && requestedChatId !== chatId) {
loadChatPreview(chatId);
}
$: if (openPreview && previewReady) {
scrollPreviewToBottom();
}
onDestroy(() => {
loadToken += 1;
});
</script>
{#if openPreview}
<LinkPreview.Portal>
<LinkPreview.Content
class="z-[9999] hidden max-h-[min(17.5rem,calc(100vh-1.5rem))] w-[20rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl bg-white shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 transition md:block dark:bg-gray-850 dark:text-white dark:ring-white/10"
{side}
{align}
{sideOffset}
>
<div class="border-b border-gray-50/60 px-3 py-2 dark:border-gray-800/25">
<div class="truncate text-[13px] font-medium leading-5 text-gray-900 dark:text-gray-100">
{title || $i18n.t('Chat')}
</div>
</div>
<div
bind:this={messagesContainerElement}
class="max-h-[min(15rem,calc(100vh-4rem))] overflow-y-auto bg-white scrollbar-hover @container dark:bg-gray-850"
>
{#if loading}
<div
class="flex w-full items-center justify-center py-8 text-gray-500 dark:text-gray-400"
>
<Spinner className="size-5" />
</div>
{:else if error}
<div
class="flex w-full items-center justify-center px-6 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
{error}
</div>
{:else if previewReady}
<Messages
className="flex w-full pt-2 pb-0 [&_.message-listitem]:!mb-1 [&_.message-listitem]:!max-w-none [&_.message-listitem]:!px-3 [&_.pb-18]:!pb-1.5 [&_.markdown-prose]:!text-xs [&_.markdown-prose]:!leading-snug [&_.whitespace-pre-wrap]:!text-xs [&_.whitespace-pre-wrap]:!leading-snug [&_.text-\[0\.9375rem\]]:!text-xs [&_.text-sm]:!text-xs [&_.tool-call-body_pre]:!text-[11px] [&_.rounded-3xl]:!rounded-2xl [&_.chat-user_.rounded-3xl]:!bg-gray-50 dark:[&_.chat-user_.rounded-3xl]:!bg-gray-800 [&_.px-4]:!px-3 [&_.py-3]:!py-2 [&_.py-1\.5]:!py-1"
chatId={`chat-hover-preview-${chatId}`}
user={$user}
prompt=""
readOnly={true}
preview={true}
{selectedModels}
atSelectedModel={null}
{history}
autoScroll={true}
messagesCount={null}
sendMessage={() => {}}
continueResponse={() => {}}
regenerateResponse={() => {}}
mergeResponses={() => {}}
chatActionHandler={() => {}}
/>
{/if}
</div>
</LinkPreview.Content>
</LinkPreview.Portal>
{/if}
@@ -9,6 +9,7 @@
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
import { LinkPreview } from 'bits-ui';
import {
archiveChatById,
cloneChatById,
@@ -37,6 +38,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChatHoverPreview from './ChatHoverPreview.svelte';
import ChatIcon from './icons/Chat.svelte';
import MoreHorizontalIcon from './icons/MoreHorizontal.svelte';
import SparklesIcon from './icons/Sparkles.svelte';
@@ -90,6 +92,7 @@
let chat = null;
let mouseOver = false;
let openPreview = false;
// Local state: tracks the last updatedAt seen while the user was viewing
// this chat. Survives prop refreshes from sidebar data re-fetches that
@@ -247,6 +250,7 @@
const onDragStart = (event) => {
event.stopPropagation();
openPreview = false;
event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
@@ -329,6 +333,7 @@
const renameHandler = async () => {
chatTitle = title;
confirmEdit = true;
openPreview = false;
await tick();
@@ -444,11 +449,18 @@
</DragGhost>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
id="sidebar-chat-group"
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={!confirmEdit && !readonly}
on:mouseenter={() => {
mouseOver = true;
}}
on:mouseleave={() => {
mouseOver = false;
}}
>
{#if confirmEdit}
<div
@@ -490,90 +502,99 @@
/>
</div>
{:else}
<a
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-2 py-[6px] {id === $chatId || confirmEdit
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: selected
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis transition"
href="/c/{id}"
on:click={() => {
dispatch('select');
if ($selectedFolder) {
selectedFolder.set(null);
}
if ($mobile) {
showSidebar.set(false);
}
// Optimistically mark as read in UI when clicked
unread = false;
lastReadAt = Date.now() / 1000;
}}
on:dblclick={async (e) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
doubleClicked = true;
renameHandler();
}}
on:mouseenter={(e) => {
mouseOver = true;
}}
on:mouseleave={(e) => {
mouseOver = false;
}}
on:focus={(e) => {}}
draggable="false"
<LinkPreview.Root
openDelay={300}
closeDelay={0}
disabled={$mobile || confirmEdit || dragged}
bind:open={openPreview}
>
{#if ownerUserId}
<Tooltip content={ownerName || 'Unknown'}>
<img
src="/api/v1/users/{ownerUserId}/profile/image"
alt=""
class="size-3.5 rounded-full shrink-0 object-cover mr-1.5"
/>
</Tooltip>
{/if}
<LinkPreview.Trigger
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-2 py-[6px] {id === $chatId || confirmEdit
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: selected
? ($settings?.highContrastMode ?? false)
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis transition"
href="/c/{id}"
onclick={() => {
openPreview = false;
dispatch('select');
<!-- Loading spinner for active chat (left side) -->
{#if active}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
</div>
{/if}
if ($selectedFolder) {
selectedFolder.set(null);
}
<div class="flex self-center flex-1 w-full min-w-0">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full" />
if ($mobile) {
showSidebar.set(false);
}
// Optimistically mark as read in UI when clicked
unread = false;
lastReadAt = Date.now() / 1000;
}}
ondblclick={async (e) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
doubleClicked = true;
renameHandler();
}}
draggable="false"
>
{#if ownerUserId}
<Tooltip content={ownerName || 'Unknown'}>
<img
src="/api/v1/users/{ownerUserId}/profile/image"
alt=""
class="size-3.5 rounded-full shrink-0 object-cover mr-1.5"
/>
</Tooltip>
{/if}
<!-- Loading spinner for active chat (left side) -->
{#if active}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
? 'font-normal text-gray-800 dark:text-gray-200'
: ''} {showInlineActions && !readonly ? 'pr-12' : ''}"
>
{title}
</div>
</div>
<!-- Time ago indicator -->
{#if (updatedAt ?? createdAt) && !showInlineActions}
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
{formatTimeAgo(updatedAt ?? createdAt)}
<div class="flex self-center flex-1 w-full min-w-0">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full"></div>
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
? 'font-normal text-gray-800 dark:text-gray-200'
: ''} {showInlineActions && !readonly ? 'pr-12' : ''}"
>
{title}
</div>
</div>
{/if}
</a>
<!-- Time ago indicator -->
{#if (updatedAt ?? createdAt) && !showInlineActions}
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
{formatTimeAgo((updatedAt ?? createdAt) as number)}
</div>
{/if}
</LinkPreview.Trigger>
<ChatHoverPreview
chatId={id}
title={chatTitle || title}
{openPreview}
side="right"
align="center"
/>
</LinkPreview.Root>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->