From 2beefef68825aed8de05f0d89981bf5d05266a3c Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 20 Jul 2026 23:40:08 +0200 Subject: [PATCH] ui: Sidebar Conversations Bulk Action + Improved Settings logic/UI (#25815) * feat: WIP * feat: Replace conversation rename flow with unified AlertDialog component * feat: Add radio group component and consolidate title generation settings * refactor: Remove JS Sandbox global toggle and migrate legacy user state * chore: Formatting * refactor: Cleanup Co-authored-by: Aleksander Grygier * refactor: Cleanup * refactor: Marquee selection hook * feat: UI improvements * refactor: Bulk db operations * fix: optimize bulk conversation deletion to handle ancestor chains * refactor: remove pairedKey mechanism from settings system * fix: remove redundant onclick handler from dialog cancel button * chore: pin @lucide/svelte to exact version * feat: Run JavaScript tool disabled by default * fix: correct active conversation deletion tracking in bulk delete * feat: improve shift-key multi-selection support in sidebar via keyboard * refactor: Retrieve JS Tool enabling via Developer Settings * nits: sync, dialog wording, cycle guard, and lockfile follow-ups - Restore titleGenerationUseLLM registry entry so it syncs across devices again - Mention fork cascade in the bulk delete confirmation dialog - Clear newParent on cycle guard break so children never point at a deleted conversation - Align @lucide/svelte in package-lock.json with the exact pin in package.json --------- Co-authored-by: Pascal --- tools/ui/package-lock.json | 8 +- tools/ui/package.json | 2 +- .../components/app/actions/ActionIcon.svelte | 9 +- .../dialogs/DialogConversationRename.svelte | 85 ++++++ .../DialogConversationSelection.svelte | 5 +- .../DialogConversationTitleUpdate.svelte | 46 ---- .../app/dialogs/DialogExportSettings.svelte | 1 + .../src/lib/components/app/dialogs/index.ts | 31 ++- .../app/misc/ConversationSelection.svelte | 84 +++--- .../SidebarNavigation.svelte | 251 ++++++++++++++---- .../SidebarNavigationActions.svelte | 14 +- .../SidebarNavigationConversationItem.svelte | 90 ++++++- .../SidebarNavigationConversationList.svelte | 204 +++++++++----- .../SidebarNavigationSearchResults.svelte | 20 +- .../SidebarNavigationSelectionBar.svelte | 163 ++++++++++++ .../lib/components/app/navigation/index.ts | 30 +++ .../SettingsChat/SettingsChatFields.svelte | 52 +++- .../SettingsChat/SettingsChatToolsTab.svelte | 15 +- .../components/ui/checkbox/checkbox.svelte | 2 +- .../lib/components/ui/radio-group/index.ts | 10 + .../ui/radio-group/radio-group-item.svelte | 31 +++ .../ui/radio-group/radio-group.svelte | 19 ++ tools/ui/src/lib/constants/settings-keys.ts | 3 +- .../ui/src/lib/constants/settings-registry.ts | 135 +++++----- tools/ui/src/lib/enums/settings.enums.ts | 3 +- .../lib/hooks/use-marquee-selection.svelte.ts | 239 +++++++++++++++++ tools/ui/src/lib/services/database.service.ts | 178 ++++++++++++- tools/ui/src/lib/stores/chat.svelte.ts | 6 +- .../ui/src/lib/stores/conversations.svelte.ts | 167 ++++++++---- tools/ui/src/lib/types/settings.d.ts | 4 + tools/ui/src/routes/+layout.svelte | 48 +--- 31 files changed, 1529 insertions(+), 426 deletions(-) create mode 100644 tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte delete mode 100644 tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte create mode 100644 tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSelectionBar.svelte create mode 100644 tools/ui/src/lib/components/ui/radio-group/index.ts create mode 100644 tools/ui/src/lib/components/ui/radio-group/radio-group-item.svelte create mode 100644 tools/ui/src/lib/components/ui/radio-group/radio-group.svelte create mode 100644 tools/ui/src/lib/hooks/use-marquee-selection.svelte.ts diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json index 7216de6823..2fe44f4c34 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -12,7 +12,7 @@ "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", "@internationalized/date": "3.12.2", - "@lucide/svelte": "0.515.0", + "@lucide/svelte": "1.25.0", "@modelcontextprotocol/sdk": "1.26.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.2.4", @@ -3065,9 +3065,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "0.515.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.515.0.tgz", - "integrity": "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", + "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/tools/ui/package.json b/tools/ui/package.json index 8b3516a02c..4ea2bf703c 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -31,7 +31,7 @@ "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", "@internationalized/date": "3.12.2", - "@lucide/svelte": "0.515.0", + "@lucide/svelte": "1.25.0", "@modelcontextprotocol/sdk": "1.26.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.2.4", diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte index 8a86557bb9..608ff6fab4 100644 --- a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte +++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte @@ -66,7 +66,14 @@ {#snippet child({ props })} - {@render button(props)} + {#if disabled} + + + {@render button({})} + + {:else} + {@render button(props)} + {/if} {/snippet} diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte new file mode 100644 index 0000000000..d85340f3fb --- /dev/null +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte @@ -0,0 +1,85 @@ + + + + + + + + Rename conversation + + + Choose a new title for this conversation. + + +
+ + + +
+ + + Cancel + + + +
+
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte index 7373250850..5f5b2f4ab3 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte @@ -37,9 +37,9 @@ - + - + Select Conversations to {mode === 'export' ? 'Export' : 'Import'} @@ -58,6 +58,7 @@ - import * as AlertDialog from '$lib/components/ui/alert-dialog'; - import { Button } from '$lib/components/ui/button'; - - interface Props { - open: boolean; - currentTitle: string; - newTitle: string; - onConfirm: () => void; - onCancel: () => void; - } - - let { open = $bindable(), currentTitle, newTitle, onConfirm, onCancel }: Props = $props(); - - - - - - Update Conversation Title? - - - Do you want to update the conversation title to match the first message content? - - - -
-
-

Current title:

- -

{currentTitle}

-
- -
-

New title would be:

- -

{newTitle}

-
-
- - - - - - -
-
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte index c112bde9f6..fe36dce56e 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte @@ -68,6 +68,7 @@ Cancel + showTitleUpdate = false} + * bind:value={renameDraft} + * onConfirm={handleRenameConfirm} + * onCancel={() => (showRename = false)} * /> * ``` */ -export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte'; +export { default as DialogConversationRename } from './DialogConversationRename.svelte'; /** * diff --git a/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte b/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte index db14fd631e..b6052b4a97 100644 --- a/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte +++ b/tools/ui/src/lib/components/app/misc/ConversationSelection.svelte @@ -4,6 +4,7 @@ import SearchInput from '$lib/components/app/forms/SearchInput.svelte'; import { ScrollArea } from '$lib/components/ui/scroll-area'; import { SvelteSet } from 'svelte/reactivity'; + import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte'; interface Props { conversations: DatabaseConversation[]; @@ -11,13 +12,20 @@ mode: 'export' | 'import'; onCancel: () => void; onConfirm: (selectedConversations: DatabaseConversation[]) => void; + isOpen?: boolean; } - let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props(); + let { + conversations, + messageCountMap = new Map(), + mode, + onCancel, + onConfirm, + isOpen = true + }: Props = $props(); let searchQuery = $state(''); let selectedIds = $state.raw>(getInitialSelectedIds()); - let lastClickedId = $state(null); function getInitialSelectedIds(): SvelteSet { return new SvelteSet(conversations.map((c) => c.id)); @@ -30,6 +38,8 @@ }) ); + let orderedIds = $derived(filteredConversations.map((c) => c.id)); + let allSelected = $derived( filteredConversations.length > 0 && filteredConversations.every((conv) => selectedIds.has(conv.id)) @@ -39,54 +49,20 @@ filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected ); - function toggleConversation(id: string, shiftKey: boolean = false) { - const newSet = new SvelteSet(selectedIds); - - if (shiftKey && lastClickedId !== null) { - const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId); - const currentIndex = filteredConversations.findIndex((c) => c.id === id); - - if (lastIndex !== -1 && currentIndex !== -1) { - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - - const shouldSelect = !newSet.has(id); - - for (let i = start; i <= end; i++) { - if (shouldSelect) { - newSet.add(filteredConversations[i].id); - } else { - newSet.delete(filteredConversations[i].id); - } - } - - selectedIds = newSet; - return; - } - } - - if (newSet.has(id)) { - newSet.delete(id); - } else { - newSet.add(id); - } - - selectedIds = newSet; - lastClickedId = id; - } + const marquee = useMarqueeSelection({ + selectedIds: () => selectedIds, + orderedIds: () => orderedIds, + enabled: () => isOpen + }); function toggleAll() { + const newSet = new SvelteSet(selectedIds); if (allSelected) { - const newSet = new SvelteSet(selectedIds); - filteredConversations.forEach((conv) => newSet.delete(conv.id)); - selectedIds = newSet; } else { - const newSet = new SvelteSet(selectedIds); - filteredConversations.forEach((conv) => newSet.add(conv.id)); - selectedIds = newSet; } + selectedIds = newSet; } function handleConfirm() { @@ -97,7 +73,7 @@ function handleCancel() { selectedIds = getInitialSelectedIds(); searchQuery = ''; - lastClickedId = null; + marquee.reset(); onCancel(); } @@ -105,7 +81,7 @@ export function reset() { selectedIds = getInitialSelectedIds(); searchQuery = ''; - lastClickedId = null; + marquee.reset(); } @@ -122,7 +98,7 @@
- + @@ -139,6 +115,7 @@ + {#if filteredConversations.length === 0} @@ -152,23 +129,28 @@ {:else} {#each filteredConversations as conv (conv.id)} + {@const checked = selectedIds.has(conv.id)} toggleConversation(conv.id, event.shiftKey)} + class="cursor-pointer border-b transition-colors hover:bg-muted/50 {checked + ? 'bg-muted/75' + : ''}" + data-conversation-row={conv.id} + onmousedown={(event) => marquee.rowMouseDown(conv.id, event)} + onclick={(event) => marquee.rowClick(conv.id, event.shiftKey)} > diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte index a23f4682e5..b5e4beeffd 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -4,15 +4,22 @@ import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte'; import { ActionIcon, + DialogConversationRename, Logo, SidebarNavigationConversationList, SidebarNavigationActions } from '$lib/components/app'; import { ROUTES } from '$lib/constants'; import { fade } from 'svelte/transition'; + import { SvelteSet } from 'svelte/reactivity'; + import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; - import { conversationsStore, conversations } from '$lib/stores/conversations.svelte'; + import { + buildConversationTree, + conversationsStore, + conversations + } from '$lib/stores/conversations.svelte'; import { chatStore } from '$lib/stores/chat.svelte'; import { config } from '$lib/stores/settings.svelte'; import { RouterService } from '$lib/services/router.service'; @@ -40,7 +47,6 @@ const isOnMobile = $derived(isMobile.current); const alwaysShowOnDesktop = $derived(config().alwaysShowSidebarOnDesktop as boolean); - // Keep the sidebar expanded on desktop when the user pins it open $effect(() => { if (alwaysShowOnDesktop && !isOnMobile) { isExpandedMode = true; @@ -58,13 +64,11 @@ if (!isExpandedMode) { isSearchModeActive = false; searchQuery = ''; + if (isSelectionMode) exitSelectionMode(); 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; @@ -89,6 +93,121 @@ return conversations(); }); + let isSelectionMode = $state(false); + let selectedIds = new SvelteSet(); + + let renameDialogOpen = $state(false); + let renameTargetConversationId = $state(null); + let renameDraft = $state(''); + let renameOriginalTitle = $state(''); + + const renderedOrderIds = $derived( + buildConversationTree(filteredConversations).map((t) => t.conversation.id) + ); + + const allSelectedArePinned = $derived.by(() => { + if (selectedIds.size === 0) return false; + const convs = conversations(); + for (const id of selectedIds) { + const c = convs.find((conv) => conv.id === id); + if (c && !c.pinned) return false; + } + return true; + }); + + const pinStateIsMixed = $derived.by(() => { + if (selectedIds.size === 0) return false; + const convs = conversations(); + let anyPinned = false; + let anyUnpinned = false; + for (const id of selectedIds) { + const c = convs.find((conv) => conv.id === id); + if (!c) continue; + if (c.pinned) anyPinned = true; + else anyUnpinned = true; + if (anyPinned && anyUnpinned) return true; + } + return false; + }); + + const visibleSelectionStats = $derived.by(() => { + const visibleIds = filteredConversations.map((c) => c.id); + let selectedVisible = 0; + for (const id of visibleIds) { + if (selectedIds.has(id)) selectedVisible++; + } + return { + visibleCount: visibleIds.length, + selectedVisibleCount: selectedVisible + }; + }); + + function enterSelectionMode(id?: string) { + isSelectionMode = true; + if (id !== undefined) { + selectedIds.add(id); + } + } + + function exitSelectionMode() { + isSelectionMode = false; + selectedIds.clear(); + } + + function toggleSelected(id: string) { + if (selectedIds.has(id)) { + selectedIds.delete(id); + } else { + selectedIds.add(id); + } + } + + function toggleSelectAllVisible() { + const visibleIds = filteredConversations.map((c) => c.id); + const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id)); + + if (allSelected) { + for (const id of visibleIds) selectedIds.delete(id); + } else { + for (const id of visibleIds) selectedIds.add(id); + } + } + + async function handleBulkDelete() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + await conversationsStore.bulkDeleteConversations(ids); + exitSelectionMode(); + } + + async function handleBulkPinToggle() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + await conversationsStore.bulkToggleConversationPin(ids); + } + + async function handleBulkExport() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + await conversationsStore.bulkExportConversations(ids); + } + + const marquee = useMarqueeSelection({ + selectedIds: () => selectedIds, + orderedIds: () => renderedOrderIds, + enabled: () => isSelectionMode + }); + + function handleRowMouseDown(id: string, event: MouseEvent) { + if (!isSelectionMode) return; + marquee.rowMouseDown(id, event); + } + + function handleSelectionClick(id: string, options: { shiftKey: boolean }): void { + if (!isSelectionMode) return; + marquee.rowClick(id, options.shiftKey); + } + async function selectConversation(id: string) { if (isMobile.current) { scheduleMobileCollapse(); @@ -100,10 +219,30 @@ 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()); - } + renameTargetConversationId = id; + renameOriginalTitle = conversation.name; + renameDraft = conversation.name; + renameDialogOpen = true; + } + + async function handleRenameConfirm() { + const id = renameTargetConversationId; + if (!id) return; + + const nextName = renameDraft.trim(); + if (!nextName || nextName === renameOriginalTitle.trim()) return; + + await conversationsStore.updateConversationName(id, nextName); + + renameDialogOpen = false; + renameTargetConversationId = null; + } + + function handleRenameCancel() { + renameDialogOpen = false; + renameTargetConversationId = null; + renameDraft = ''; + renameOriginalTitle = ''; } async function handleDeleteConversation(id: string) { @@ -148,9 +287,7 @@ {#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))} {/if} + +
Messages
{ event.preventDefault(); event.stopPropagation(); - toggleConversation(conv.id, event.shiftKey); + marquee.rowClick(conv.id, event.shiftKey); }} /> -
+
{conv.name || 'Untitled conversation'}