From 4712544d5e5d4410facf6eea51f3ca4ae54723c9 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:16:03 -0400 Subject: [PATCH] feat(search): add chat context menu to search modal (#25490) --- src/lib/components/layout/SearchModal.svelte | 438 ++++++++++++++++-- .../components/layout/Sidebar/ChatMenu.svelte | 2 + 2 files changed, 412 insertions(+), 28 deletions(-) diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index 70942fca91..de81b379ab 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -5,7 +5,18 @@ import Modal from '$lib/components/common/Modal.svelte'; import SearchInput from './Sidebar/SearchInput.svelte'; - import { getChatById, getChatList, getChatListBySearchText } from '$lib/apis/chats'; + import { + getChatById, + getChatList, + getChatListBySearchText, + cloneChatById, + deleteChatById, + archiveChatById, + updateChatById, + updateChatFolderIdById, + getPinnedChatList, + getAllTags + } from '$lib/apis/chats'; import Spinner from '../common/Spinner.svelte'; import dayjs from '$lib/dayjs'; @@ -13,17 +24,234 @@ import calendar from 'dayjs/plugin/calendar'; import Loader from '../common/Loader.svelte'; import { createMessagesList } from '$lib/utils'; - import { config, user } from '$lib/stores'; + import { + config, + user, + chats, + chatId as currentChatId, + pinnedChats, + currentChatPage, + tags + } from '$lib/stores'; import Messages from '../chat/Messages.svelte'; import { goto } from '$app/navigation'; import PencilSquare from '../icons/PencilSquare.svelte'; import PageEdit from '../icons/PageEdit.svelte'; + + import ChatMenu from './Sidebar/ChatMenu.svelte'; + import ShareChatModal from '../chat/ShareChatModal.svelte'; + import DeleteConfirmDialog from '../common/ConfirmDialog.svelte'; + import Tooltip from '../common/Tooltip.svelte'; + import Sparkles from '../icons/Sparkles.svelte'; + import ArchiveBox from '../icons/ArchiveBox.svelte'; + import GarbageBin from '../icons/GarbageBin.svelte'; + import { generateTitle } from '$lib/apis'; dayjs.extend(calendar); dayjs.extend(localizedFormat); export let show = false; export let onClose = () => {}; + let showShareChatModal = false; + let showDeleteConfirm = false; + let menuChatId = ''; + let menuChatTitle = ''; + + let editingChatId = null; + let editingChatTitle = ''; + + let shiftKey = false; + + const onShiftKeyDown = (e) => { + if (e.key === 'Shift') shiftKey = true; + }; + + const onShiftKeyUp = (e) => { + if (e.key === 'Shift') shiftKey = false; + }; + let generating = false; + + const refreshSidebar = async () => { + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + await pinnedChats.set(await getPinnedChatList(localStorage.token)); + }; + + const cloneChatHandler = async (id) => { + const chat = chatList?.find((c) => c.id === id); + const res = await cloneChatById( + localStorage.token, + id, + $i18n.t('Clone of {{TITLE}}', { + TITLE: chat?.title ?? 'Chat' + }) + ).catch((error) => { + toast.error(`${error}`); + return null; + }); + + if (res) { + await refreshSidebar(); + await searchHandler(); + } + }; + + const archiveChatHandler = async (id) => { + try { + await archiveChatById(localStorage.token, id); + + chatList = chatList?.filter((c) => c.id !== id) ?? null; + + if ($currentChatId === id) { + await goto('/'); + currentChatId.set(''); + } + + await refreshSidebar(); + toast.success($i18n.t('Chat archived.')); + } catch (error) { + toast.error($i18n.t('Failed to archive chat.')); + } + }; + + const deleteChatHandler = async (id) => { + const res = await deleteChatById(localStorage.token, id).catch((error) => { + toast.error(`${error}`); + return null; + }); + + if (res) { + chatList = chatList?.filter((c) => c.id !== id) ?? null; + tags.set(await getAllTags(localStorage.token)); + + if ($currentChatId === id) { + await goto('/'); + currentChatId.set(''); + } + + await refreshSidebar(); + } + }; + + const moveChatHandler = async (chatId, folderId) => { + if (chatId && folderId) { + const res = await updateChatFolderIdById(localStorage.token, chatId, folderId).catch( + (error) => { + toast.error(`${error}`); + return null; + } + ); + + if (res) { + chatList = chatList?.filter((c) => c.id !== chatId) ?? null; + await refreshSidebar(); + toast.success($i18n.t('Chat moved successfully')); + } + } + }; + + const renameHandler = async (id) => { + editingChatId = id; + editingChatTitle = chatList?.find((c) => c.id === id)?.title ?? ''; + + await tick(); + const input = document.getElementById(`search-chat-title-input-${id}`); + if (input) { + input.focus(); + input.select(); + } + }; + + const confirmRename = async () => { + if (!editingChatId) return; + + const trimmed = editingChatTitle.trim(); + if (trimmed === '') { + toast.error($i18n.t('Title cannot be an empty string.')); + return; + } + + await updateChatById(localStorage.token, editingChatId, { title: trimmed }); + + if (chatList) { + chatList = chatList.map((c) => (c.id === editingChatId ? { ...c, title: trimmed } : c)); + } + + editingChatId = null; + editingChatTitle = ''; + await refreshSidebar(); + }; + + const cancelRename = () => { + editingChatId = null; + editingChatTitle = ''; + }; + + const generateTitleHandler = async () => { + if (!editingChatId || generating) return; + + generating = true; + const chat = await getChatById(localStorage.token, editingChatId).catch(() => null); + + if (!chat) { + toast.error($i18n.t('Failed to load chat')); + generating = false; + return; + } + + const chatContent = chat.chat; + const history = chatContent?.history; + let msgList = []; + + if (history?.messages && history?.currentId) { + msgList = createMessagesList(history, history.currentId).map((m) => ({ + role: m.role, + content: m.content + })); + } else { + msgList = (chatContent?.messages ?? []).map((m) => ({ + role: m.role, + content: m.content + })); + } + + let model = ''; + if (history?.messages && history?.currentId) { + let currentId = history.currentId; + while (currentId) { + const msg = history.messages[currentId]; + if (!msg) break; + if (msg.role === 'assistant' && msg.model) { + model = msg.model; + break; + } + currentId = msg.parentId; + } + } + if (!model) { + model = chatContent?.models?.at(0) ?? ''; + } + + editingChatTitle = ''; + + const generatedTitle = await generateTitle(localStorage.token, model, msgList).catch( + (error) => { + toast.error(`${error}`); + return null; + } + ); + + if (generatedTitle) { + editingChatTitle = generatedTitle; + } + + generating = false; + + if (generatedTitle) { + await confirmRename(); + } + }; + let actions = [ { label: $i18n.t('Start a new conversation'), @@ -173,6 +401,10 @@ $: if (show) { searchHandler(); + } else { + editingChatId = null; + editingChatTitle = ''; + generating = false; } const onKeyDown = (e) => { @@ -187,6 +419,11 @@ return; } + // Don't handle navigation/activation keys while editing a chat title + if (editingChatId) { + return; + } + if (e.code === 'Escape') { show = false; onClose(); @@ -252,6 +489,8 @@ ]; document.addEventListener('keydown', onKeyDown); + document.addEventListener('keydown', onShiftKeyDown); + document.addEventListener('keyup', onShiftKeyUp); }); onDestroy(() => { @@ -259,9 +498,25 @@ clearTimeout(searchDebounceTimeout); } document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keydown', onShiftKeyDown); + document.removeEventListener('keyup', onShiftKeyUp); }); + + + { + deleteChatHandler(menuChatId); + }} +> +
+ {$i18n.t('This will delete')} {menuChatTitle}. +
+
+
@@ -373,42 +628,169 @@
{/if} - { selectedIdx = idx + actions.length; }} - on:click={async () => { - await goto(`/c/${chat.id}`); - show = false; - onClose(); - }} > -
-
- {chat?.title} + {#if editingChatId === chat.id} +
+ { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + confirmRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelRename(); + } + }} + on:blur={() => { + if (!generating) { + confirmRename(); + } + }} + /> +
+ +
+ + + +
+ {:else} +
{ + await goto(`/c/${chat.id}`); + show = false; + onClose(); + }} + > +
+ {chat?.title} +
+
+ {/if} + +
+ {#if editingChatId !== chat.id} + {#if shiftKey} + + {:else} + + {/if} + {/if} + +
+ {$i18n.t( + dayjs(chat?.updated_at * 1000).calendar(null, { + sameDay: '[Today]', + nextDay: '[Tomorrow]', + nextWeek: 'dddd', + lastDay: '[Yesterday]', + lastWeek: '[Last] dddd', + sameElse: 'L' + }) + )}
- -
- {$i18n.t( - dayjs(chat?.updated_at * 1000).calendar(null, { - sameDay: '[Today]', - nextDay: '[Tomorrow]', - nextWeek: 'dddd', - lastDay: '[Yesterday]', - lastWeek: '[Last] dddd', - sameElse: 'L' // use localized format, otherwise dayjs.calendar() defaults to DD/MM/YYYY - }) - )} -
- +
{/each} {#if !allChatsLoaded} diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 96e8a997ce..13731e6528 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -362,6 +362,7 @@ draggable="false" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full" on:click={() => { + show = false; renameHandler(); }} > @@ -375,6 +376,7 @@ draggable="false" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full" on:click={() => { + show = false; pinHandler(); }} >