]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Sidebar Conversations Bulk Action + Improved Settings logic/UI (#25815)
authorAleksander Grygier <redacted>
Mon, 20 Jul 2026 21:40:08 +0000 (23:40 +0200)
committerGitHub <redacted>
Mon, 20 Jul 2026 21:40:08 +0000 (23:40 +0200)
* 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 <redacted>
* 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 <redacted>
31 files changed:
tools/ui/package-lock.json
tools/ui/package.json
tools/ui/src/lib/components/app/actions/ActionIcon.svelte
tools/ui/src/lib/components/app/dialogs/DialogConversationRename.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/dialogs/DialogConversationSelection.svelte
tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte [deleted file]
tools/ui/src/lib/components/app/dialogs/DialogExportSettings.svelte
tools/ui/src/lib/components/app/dialogs/index.ts
tools/ui/src/lib/components/app/misc/ConversationSelection.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationList.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSearchResults.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSelectionBar.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/navigation/index.ts
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
tools/ui/src/lib/components/ui/checkbox/checkbox.svelte
tools/ui/src/lib/components/ui/radio-group/index.ts [new file with mode: 0644]
tools/ui/src/lib/components/ui/radio-group/radio-group-item.svelte [new file with mode: 0644]
tools/ui/src/lib/components/ui/radio-group/radio-group.svelte [new file with mode: 0644]
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/enums/settings.enums.ts
tools/ui/src/lib/hooks/use-marquee-selection.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/services/database.service.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/types/settings.d.ts
tools/ui/src/routes/+layout.svelte

index 7216de682340876a48c0bc06647159cb1f25ba21..2fe44f4c34f71e969d11279edb89ed3e2656359a 100644 (file)
@@ -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",
                        }
                },
                "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": {
index 8b3516a02cf262e7d9a48b031c1ebb8d761a4344..4ea2bf703c355c6a65a4b92d4ab456213cb0bd58 100644 (file)
@@ -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",
index 8a86557bb98d35d796af90ecc2b48c8b57776893..608ff6fab4b24b14c408faabd84082bdb96fcf70 100644 (file)
                <Tooltip.Trigger>
                        <!-- prevent another nested button element -->
                        {#snippet child({ props })}
-                               {@render button(props)}
+                               {#if disabled}
+                                       <!-- disabled buttons have pointer-events:none; wrap in a span so the tooltip hover surface stays alive -->
+                                       <span {...props}>
+                                               {@render button({})}
+                                       </span>
+                               {:else}
+                                       {@render button(props)}
+                               {/if}
                        {/snippet}
                </Tooltip.Trigger>
 
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 (file)
index 0000000..d85340f
--- /dev/null
@@ -0,0 +1,85 @@
+<script lang="ts">
+       import * as AlertDialog from '$lib/components/ui/alert-dialog';
+       import { Button } from '$lib/components/ui/button';
+       import { Input } from '$lib/components/ui/input';
+       import { Pencil } from '@lucide/svelte';
+
+       interface Props {
+               open: boolean;
+               currentTitle: string;
+               value: string;
+               onConfirm: () => void;
+               onCancel: () => void;
+       }
+
+       let {
+               open = $bindable(),
+               currentTitle,
+               value = $bindable(''),
+               onConfirm,
+               onCancel
+       }: Props = $props();
+
+       let inputRef = $state<HTMLInputElement | null>(null);
+
+       const canSubmit = $derived(value.trim().length > 0 && value.trim() !== currentTitle.trim());
+
+       $effect(() => {
+               if (open) {
+                       value = currentTitle;
+                       queueMicrotask(() => {
+                               inputRef?.focus();
+                               inputRef?.select();
+                       });
+               }
+       });
+
+       function handleOpenChange(newOpen: boolean) {
+               if (!newOpen) {
+                       onCancel();
+               }
+       }
+
+       function handleSubmit(event: Event) {
+               event.preventDefault();
+               if (!canSubmit) return;
+               value = value.trim();
+               onConfirm();
+       }
+</script>
+
+<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
+       <AlertDialog.Content>
+               <AlertDialog.Header>
+                       <AlertDialog.Title class="flex items-center gap-2">
+                               <Pencil class="h-5 w-5" />
+                               Rename conversation
+                       </AlertDialog.Title>
+
+                       <AlertDialog.Description>Choose a new title for this conversation.</AlertDialog.Description>
+               </AlertDialog.Header>
+
+               <form onsubmit={handleSubmit} class="space-y-2 pt-2 pb-4">
+                       <label for="conversation-rename-input" class="text-sm font-medium text-muted-foreground">
+                               Conversation title
+                       </label>
+
+                       <Input
+                               id="conversation-rename-input"
+                               bind:ref={inputRef}
+                               bind:value
+                               placeholder="Conversation title"
+                               maxlength={200}
+                               autocomplete="off"
+                               autocorrect="off"
+                               spellcheck={false}
+                       />
+               </form>
+
+               <AlertDialog.Footer>
+                       <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
+
+                       <Button type="button" onclick={handleSubmit} disabled={!canSubmit}>Save</Button>
+               </AlertDialog.Footer>
+       </AlertDialog.Content>
+</AlertDialog.Root>
index 7373250850ccf8882c541e18a16cab79e709128b..5f5b2f4ab315497e240bb779e85049b046554f03 100644 (file)
@@ -37,9 +37,9 @@
 
 <Dialog.Root bind:open>
        <Dialog.Portal>
-               <Dialog.Overlay class="z-[1000000]" />
+               <Dialog.Overlay class="z-1000000" />
 
-               <Dialog.Content class="z-[1000001] max-w-2xl">
+               <Dialog.Content class="z-1000001 max-w-2xl">
                        <Dialog.Header>
                                <Dialog.Title>
                                        Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
@@ -58,6 +58,7 @@
 
                        <ConversationSelection
                                bind:this={conversationSelectionRef}
+                               isOpen={open}
                                {conversations}
                                {messageCountMap}
                                {mode}
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte b/tools/ui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte
deleted file mode 100644 (file)
index 4a9ecce..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<script lang="ts">
-       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();
-</script>
-
-<AlertDialog.Root bind:open>
-       <AlertDialog.Content>
-               <AlertDialog.Header>
-                       <AlertDialog.Title>Update Conversation Title?</AlertDialog.Title>
-
-                       <AlertDialog.Description>
-                               Do you want to update the conversation title to match the first message content?
-                       </AlertDialog.Description>
-               </AlertDialog.Header>
-
-               <div class="space-y-4 pt-2 pb-6">
-                       <div class="space-y-2">
-                               <p class="text-sm font-medium text-muted-foreground">Current title:</p>
-
-                               <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{currentTitle}</p>
-                       </div>
-
-                       <div class="space-y-2">
-                               <p class="text-sm font-medium text-muted-foreground">New title would be:</p>
-
-                               <p class="rounded-md bg-muted/50 p-3 text-sm font-medium">{newTitle}</p>
-                       </div>
-               </div>
-
-               <AlertDialog.Footer>
-                       <Button variant="outline" onclick={onCancel}>Keep Current Title</Button>
-
-                       <Button onclick={onConfirm}>Update Title</Button>
-               </AlertDialog.Footer>
-       </AlertDialog.Content>
-</AlertDialog.Root>
index c112bde9f6828e3a99157eba16c336ffe149958d..fe36dce56e526a758eba65add98a91133727ce02 100644 (file)
@@ -68,6 +68,7 @@
 
                <AlertDialog.Footer>
                        <AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
+
                        <AlertDialog.Action
                                onclick={onConfirm}
                                class="bg-destructive text-white hover:bg-destructive/80"
index 29136308cef50404f26f7412442669cfc408d280..38aa0993549a450bc67d7f46c4a4afa7d15f1fe2 100644 (file)
@@ -92,33 +92,36 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
 export { default as DialogConfirmation } from './DialogConfirmation.svelte';
 
 /**
- * **DialogConversationTitleUpdate** - Conversation rename confirmation
+ * **DialogConversationRename** - Rename a conversation
  *
- * Confirmation dialog shown when editing the first user message in a conversation.
- * Asks user whether to update the conversation title to match the new message content.
+ * Modal dialog for renaming a conversation. Replaces the prior
+ * `window.prompt()`-based flow with a styled, accessible AlertDialog
+ * containing an editable input. Triggered from the sidebar conversation
+ * item's "Edit" action.
  *
  * **Architecture:**
  * - Uses ShadCN AlertDialog
- * - Shows current vs proposed title comparison
- * - Triggered by ChatMessages when first message is edited
+ * - Bindable `value` keeps the new title in sync with parent state
+ * - Submit is gated on a non-empty trimmed value that differs from the current title
  *
  * **Features:**
- * - Side-by-side display of current and new title
- * - "Keep Current Title" and "Update Title" action buttons
- * - Styled title previews in muted background boxes
+ * - Autofocus on open with text selected for quick overwrite
+ * - Disabled Save button when value is empty or unchanged
+ * - Trim-on-submit normalization
+ * - Cancel via AlertDialog.Cancel or `onOpenChange(false)`
  *
  * @example
  * ```svelte
- * <DialogConversationTitleUpdate
- *   bind:open={showTitleUpdate}
+ * <DialogConversationRename
+ *   bind:open={showRename}
  *   currentTitle={conversation.name}
- *   newTitle={truncatedMessageContent}
- *   onConfirm={updateTitle}
- *   onCancel={() => showTitleUpdate = false}
+ *   bind:value={renameDraft}
+ *   onConfirm={handleRenameConfirm}
+ *   onCancel={() => (showRename = false)}
  * />
  * ```
  */
-export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte';
+export { default as DialogConversationRename } from './DialogConversationRename.svelte';
 
 /**
  *
index db14fd631e3193db0b04b854b49cb2a9bd11db6d..b6052b4a97bf182dfdf732ea500511c502715245 100644 (file)
@@ -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[];
                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<SvelteSet<string>>(getInitialSelectedIds());
-       let lastClickedId = $state<string | null>(null);
 
        function getInitialSelectedIds(): SvelteSet<string> {
                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))
                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();
        }
        export function reset() {
                selectedIds = getInitialSelectedIds();
                searchQuery = '';
-               lastClickedId = null;
+               marquee.reset();
        }
 </script>
 
        </div>
 
        <div class="overflow-hidden rounded-md border">
-               <ScrollArea class="h-[400px]">
+               <ScrollArea class="h-100">
                        <table class="w-full">
                                <thead class="sticky top-0 z-10 bg-muted">
                                        <tr class="border-b">
                                                <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
                                        </tr>
                                </thead>
+
                                <tbody>
                                        {#if filteredConversations.length === 0}
                                                <tr>
                                                </tr>
                                        {:else}
                                                {#each filteredConversations as conv (conv.id)}
+                                                       {@const checked = selectedIds.has(conv.id)}
                                                        <tr
-                                                               class="cursor-pointer border-b transition-colors hover:bg-muted/50"
-                                                               onclick={(event) => 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)}
                                                        >
                                                                <td class="p-3">
                                                                        <Checkbox
-                                                                               checked={selectedIds.has(conv.id)}
+                                                                               {checked}
                                                                                onclick={(event) => {
                                                                                        event.preventDefault();
                                                                                        event.stopPropagation();
-                                                                                       toggleConversation(conv.id, event.shiftKey);
+                                                                                       marquee.rowClick(conv.id, event.shiftKey);
                                                                                }}
                                                                        />
                                                                </td>
 
                                                                <td class="p-3 text-sm">
-                                                                       <div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}>
+                                                                       <div class="max-w-68 truncate" title={conv.name || 'Untitled conversation'}>
                                                                                {conv.name || 'Untitled conversation'}
                                                                        </div>
                                                                </td>
index a23f4682e5818b67f33417fc58188b6c4d2fcf00..b5e4beeffd92016bad4179f90acd46df7cec437f 100644 (file)
@@ -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;
                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;
                return conversations();
        });
 
+       let isSelectionMode = $state(false);
+       let selectedIds = new SvelteSet<string>();
+
+       let renameDialogOpen = $state(false);
+       let renameTargetConversationId = $state<string | null>(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();
                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) {
 {#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
                                        : 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'
                ]}
        >
                        {/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}
+               <div
+                       class="mt-2 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}
+                               <div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
                                        <SidebarNavigationConversationList
                                                class="px-2"
                                                {filteredConversations}
                                                {currentChatId}
                                                {isSearchModeActive}
                                                {searchQuery}
+                                               {isSelectionMode}
+                                               {selectedIds}
                                                onSelect={selectConversation}
                                                onEdit={handleEditConversation}
                                                onDelete={handleDeleteConversation}
                                                onStop={handleStopGeneration}
+                                               onToggleSelect={toggleSelected}
+                                               onEnterSelectionMode={enterSelectionMode}
+                                               onSelectionClick={handleSelectionClick}
+                                               onRowMouseDown={handleRowMouseDown}
+                                               visibleCount={visibleSelectionStats.visibleCount}
+                                               allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
+                                                       visibleSelectionStats.selectedVisibleCount === visibleSelectionStats.visibleCount}
+                                               someVisibleSelected={visibleSelectionStats.selectedVisibleCount > 0 &&
+                                                       visibleSelectionStats.selectedVisibleCount < visibleSelectionStats.visibleCount}
+                                               {allSelectedArePinned}
+                                               {pinStateIsMixed}
+                                               onSelectAllToggle={toggleSelectAllVisible}
+                                               onBulkPinToggle={handleBulkPinToggle}
+                                               onBulkExport={handleBulkExport}
+                                               onBulkDelete={handleBulkDelete}
+                                               onCloseSelection={exitSelectionMode}
                                        />
-                               {/if}
-                       </div>
+                               </div>
+                       {/if}
                </div>
        </aside>
 {/if}
 
+<DialogConversationRename
+       bind:open={renameDialogOpen}
+       currentTitle={renameOriginalTitle}
+       bind:value={renameDraft}
+       onConfirm={handleRenameConfirm}
+       onCancel={handleRenameCancel}
+/>
+
 <style>
        aside {
                @media (max-width: 768px) {
index ddcaf86b1afbcc7d56eafaf0912ce3e600f86010..5cb805ce888dfcf45f4975c907a900c38d902d26 100644 (file)
                                        : onSearchClick}
                        {@const itemTransition = {
                                duration: ICON_STRIP_TRANSITION_DURATION,
-                               delay: !initialized
-                                       ? ICON_STRIP_TRANSITION_DELAY_MULTIPLIER + i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER
-                                       : 0,
+                               delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
                                easing: circIn
                        }}
 
                                                        {@render itemIcon(item.icon)}
 
                                                        {#if showIcons}
-                                                               <span
-                                                                       in:fade={{ duration: 150, easing: circIn, delay: 50 }}
-                                                                       out:fade={{ duration: 100 }}
-                                                                       class="min-w-0 truncate">{item.tooltip}</span
+                                                               <span in:fade={itemTransition} out:fade={itemTransition} class="min-w-0 truncate"
+                                                                       >{item.tooltip}</span
                                                                >
                                                        {/if}
                                                </span>
                                        : onSearchClick}
                        {@const itemTransition = {
                                duration: ICON_STRIP_TRANSITION_DURATION,
-                               delay: !initialized
-                                       ? ICON_STRIP_TRANSITION_DELAY_MULTIPLIER + i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER
-                                       : 0,
+                               delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
                                easing: circIn
                        }}
 
index 08c66a8a9768cc3745e74525b11943f8ac49d180..7204d7fec293566e34b08912cc452de84f81b29d 100644 (file)
@@ -9,10 +9,12 @@
                Square,
                GitBranch,
                Pin,
-               PinOff
+               PinOff,
+               ListChecks
        } from '@lucide/svelte';
        import { DropdownMenuActions } from '$lib/components/app';
        import * as Tooltip from '$lib/components/ui/tooltip';
+       import { Checkbox } from '$lib/components/ui/checkbox';
        import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
        import { RouterService } from '$lib/services/router.service';
        import { getAllLoadingChats } from '$lib/stores/chat.svelte';
                isActive?: boolean;
                depth?: number;
                conversation: DatabaseConversation;
+               isSelectionMode?: boolean;
+               isSelected?: boolean;
                onDelete?: (id: string) => void;
                onEdit?: (id: string) => void;
                onSelect?: (id: string) => void;
                onStop?: (id: string) => void;
+               onToggleSelect?: (id: string) => void;
+               onEnterSelectionMode?: (id: string) => void;
+               onSelectionClick?: (id: string, options: { shiftKey: boolean }) => void;
+               onRowMouseDown?: (id: string, event: MouseEvent) => void;
        }
 
        let {
                onEdit,
                onSelect,
                onStop,
+               onToggleSelect,
+               onEnterSelectionMode,
+               onSelectionClick,
+               onRowMouseDown,
                isActive = false,
+               isSelectionMode = false,
+               isSelected = false,
                depth = 0
        }: Props = $props();
 
                conversationsStore.toggleConversationPin(conversation.id);
        }
 
+       function handleEnterSelectionMode(event: Event) {
+               event.stopPropagation();
+               onEnterSelectionMode?.(conversation.id);
+       }
+
        function handleGlobalEditEvent(event: Event) {
                const customEvent = event as CustomEvent<{ conversationId: string }>;
 
        }
 
        function handleMouseOver() {
+               if (isSelectionMode) return;
                renderActionsDropdown = true;
        }
 
-       function handleSelect() {
-               onSelect?.(conversation.id);
+       function handleSelect(event: MouseEvent) {
+               if (isSelectionMode) {
+                       onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
+               } else {
+                       onSelect?.(conversation.id);
+               }
+       }
+
+       function handleCheckboxClick(event: MouseEvent) {
+               event.stopPropagation();
+               if (isSelectionMode) {
+                       onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
+               } else {
+                       onToggleSelect?.(conversation.id);
+               }
+       }
+
+       function handleRowMouseDown(event: MouseEvent) {
+               onRowMouseDown?.(conversation.id, event);
+       }
+
+       function handleCheckboxKeydown(event: KeyboardEvent) {
+               if (event.key !== ' ' && event.key !== 'Enter') return;
+               event.stopPropagation();
+               event.preventDefault();
+               if (isSelectionMode) {
+                       onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
+               } else {
+                       onToggleSelect?.(conversation.id);
+               }
        }
 
        $effect(() => {
 <button
        class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
                ? 'bg-foreground/5 text-accent-foreground'
-               : ''} px-3"
-       onclick={handleSelect}
+               : ''} {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectionMode
+               ? 'is-selection-mode'
+               : ''} px-2"
+       data-conversation-row={conversation.id}
+       onclick={(e) => handleSelect(e)}
        onmouseover={handleMouseOver}
        onmouseleave={handleMouseLeave}
+       onmousedown={(e) => handleRowMouseDown(e)}
        onfocusin={handleMouseOver}
        onfocusout={(e) => {
                if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
                class="flex min-w-0 flex-1 items-center gap-2"
                style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
        >
+               {#if isSelectionMode}
+                       <div
+                               class="shrink-0"
+                               onclick={(e) => handleCheckboxClick(e)}
+                               onkeydown={handleCheckboxKeydown}
+                               role="checkbox"
+                               aria-checked={isSelected}
+                               aria-label={isSelected ? `Deselect ${conversation.name}` : `Select ${conversation.name}`}
+                               tabindex="-1"
+                       >
+                               <Checkbox
+                                       checked={isSelected}
+                                       aria-label={isSelected ? `Deselect ${conversation.name}` : `Select ${conversation.name}`}
+                               />
+                       </div>
+               {/if}
+
                {#if depth > 0}
                        <Tooltip.Root>
                                <Tooltip.Trigger>
                <TruncatedText text={conversation.name} class="text-sm font-medium" showTooltip={false} />
        </div>
 
-       {#if renderActionsDropdown}
+       {#if !isSelectionMode && renderActionsDropdown}
                <div class="actions flex items-center">
                        <DropdownMenuActions
                                triggerIcon={MoreHorizontal}
                                                },
                                                shortcut: ['shift', 'cmd', 's']
                                        },
+                                       {
+                                               icon: ListChecks,
+                                               label: 'Select',
+                                               onclick: handleEnterSelectionMode
+                                       },
                                        {
                                                icon: Trash2,
                                                label: 'Delete',
                        }
                }
 
+               &.is-selection-mode :global([data-slot='dropdown-menu-trigger']) {
+                       display: none !important;
+               }
+
                .stop-button {
                        :global(.stop-icon) {
                                display: none;
index 488e96bbcf617285bc1129f5cc9c9e023974727b..1ea955319a0ca64e37473998241b65fb6a6b8810 100644 (file)
@@ -3,6 +3,7 @@
        import { buildConversationTree } from '$lib/stores/conversations.svelte';
        import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
        import SidebarNavigationSearchResults from './SidebarNavigationSearchResults.svelte';
+       import SidebarNavigationSelectionBar from './SidebarNavigationSelectionBar.svelte';
 
        interface Props {
                class: string;
                currentChatId: string | undefined;
                isSearchModeActive: boolean;
                searchQuery: string;
+               isSelectionMode?: boolean;
+               selectedIds?: Set<string>;
                onSelect: (id: string) => void;
                onEdit: (id: string) => void;
                onDelete: (id: string) => void;
                onStop: (id: string) => void;
+               onToggleSelect?: (id: string) => void;
+               onEnterSelectionMode?: (id: string) => void;
+               onSelectionClick?: (id: string, options: { shiftKey: boolean }) => void;
+               onRowMouseDown?: (id: string, event: MouseEvent) => void;
+               visibleCount: number;
+               allVisibleSelected: boolean;
+               someVisibleSelected: boolean;
+               allSelectedArePinned: boolean;
+               pinStateIsMixed: boolean;
+               onSelectAllToggle: () => void;
+               onBulkPinToggle: () => void;
+               onBulkExport: () => void;
+               onBulkDelete: () => void;
+               onCloseSelection: () => void;
        }
 
        let {
                currentChatId,
                isSearchModeActive,
                searchQuery,
+               isSelectionMode = false,
+               selectedIds = new Set<string>(),
                onSelect,
                onEdit,
                onDelete,
-               onStop
+               onStop,
+               onToggleSelect,
+               onEnterSelectionMode,
+               onSelectionClick,
+               onRowMouseDown,
+               visibleCount,
+               allVisibleSelected,
+               someVisibleSelected,
+               allSelectedArePinned,
+               pinStateIsMixed,
+               onSelectAllToggle,
+               onBulkPinToggle,
+               onBulkExport,
+               onBulkDelete,
+               onCloseSelection
        }: Props = $props();
 
        let conversationTree = $derived(buildConversationTree(filteredConversations));
        );
 </script>
 
-{#if isSearchModeActive}
-       <SidebarNavigationSearchResults
-               class={className}
-               {searchQuery}
-               {filteredConversations}
-               {currentChatId}
-               {onSelect}
-               {onEdit}
-               {onDelete}
-               {onStop}
-       />
-{:else}
-       {#if pinnedConversations.length > 0}
-               <div class="py-2 flex whitespace-nowrap {className}">
-                       <div
-                               class="text-muted-foreground inline-flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium gap-1"
-                       >
-                               <Pin class="h-3.5 w-3.5" />
+<div class="flex min-h-0 flex-1 flex-col">
+       {#if isSearchModeActive}
+               <SidebarNavigationSearchResults
+                       class={className}
+                       {searchQuery}
+                       {filteredConversations}
+                       {currentChatId}
+                       {onSelect}
+                       {onEdit}
+                       {onDelete}
+                       {onStop}
+                       {isSelectionMode}
+                       {selectedIds}
+                       {onToggleSelect}
+                       {onEnterSelectionMode}
+                       {onSelectionClick}
+                       {onRowMouseDown}
+               />
+       {:else}
+               {#if pinnedConversations.length > 0}
+                       <div class="py-2 flex whitespace-nowrap {className}">
+                               <div
+                                       class="text-muted-foreground inline-flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium gap-1"
+                               >
+                                       <Pin class="h-3.5 w-3.5" />
 
-                               <span>Pinned</span>
+                                       <span>Pinned</span>
+                               </div>
                        </div>
-               </div>
-
-               <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1 {className}">
-                       {#each pinnedConversations as { conversation, depth } (conversation.id)}
-                               <li class="group/item relative mb-1 p-0">
-                                       <SidebarNavigationConversationItem
-                                               conversation={{
-                                                       id: conversation.id,
-                                                       name: conversation.name,
-                                                       lastModified: conversation.lastModified,
-                                                       currNode: conversation.currNode,
-                                                       forkedFromConversationId: conversation.forkedFromConversationId,
-                                                       pinned: conversation.pinned
-                                               }}
-                                               {depth}
-                                               isActive={currentChatId === conversation.id}
-                                               {onSelect}
-                                               {onEdit}
-                                               {onDelete}
-                                               {onStop}
-                                       />
-                               </li>
-                       {/each}
-               </ul>
-       {/if}
-
-       <div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-2 whitespace-nowrap {className}">
-               {#if filteredConversations.length > 0}
-                       <div
-                               class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
-                       >
-                               Recent conversations
-                       </div>
-               {/if}
 
-               <div class="min-h-0 flex-1 md:overflow-y-auto">
-                       <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1">
-                               {#each unpinnedConversations as { conversation, depth } (conversation.id)}
+                       <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1 {className}">
+                               {#each pinnedConversations as { conversation, depth } (conversation.id)}
                                        <li class="group/item relative mb-1 p-0">
                                                <SidebarNavigationConversationItem
                                                        conversation={{
                                                        }}
                                                        {depth}
                                                        isActive={currentChatId === conversation.id}
+                                                       {isSelectionMode}
+                                                       isSelected={selectedIds.has(conversation.id)}
                                                        {onSelect}
                                                        {onEdit}
                                                        {onDelete}
                                                        {onStop}
+                                                       {onToggleSelect}
+                                                       {onEnterSelectionMode}
+                                                       {onSelectionClick}
+                                                       {onRowMouseDown}
                                                />
                                        </li>
                                {/each}
-
-                               {#if unpinnedConversations.length === 0}
-                                       <li class="px-2 py-4 text-center">
-                                               <p class="mb-4 p-4 text-sm text-muted-foreground">
-                                                       {recentEmptyMessage}
-                                               </p>
-                                       </li>
-                               {/if}
                        </ul>
+               {/if}
+
+               <div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-0 whitespace-nowrap {className}">
+                       {#if filteredConversations.length > 0}
+                               <div
+                                       class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
+                               >
+                                       Recent conversations
+                               </div>
+                       {/if}
+
+                       <div class="min-h-0 flex-1 md:overflow-y-auto">
+                               <ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0">
+                                       {#each unpinnedConversations as { conversation, depth } (conversation.id)}
+                                               <li class="group/item relative mb-1 p-0">
+                                                       <SidebarNavigationConversationItem
+                                                               conversation={{
+                                                                       id: conversation.id,
+                                                                       name: conversation.name,
+                                                                       lastModified: conversation.lastModified,
+                                                                       currNode: conversation.currNode,
+                                                                       forkedFromConversationId: conversation.forkedFromConversationId,
+                                                                       pinned: conversation.pinned
+                                                               }}
+                                                               {depth}
+                                                               isActive={currentChatId === conversation.id}
+                                                               {isSelectionMode}
+                                                               isSelected={selectedIds.has(conversation.id)}
+                                                               {onSelect}
+                                                               {onEdit}
+                                                               {onDelete}
+                                                               {onStop}
+                                                               {onToggleSelect}
+                                                               {onEnterSelectionMode}
+                                                               {onSelectionClick}
+                                                               {onRowMouseDown}
+                                                       />
+                                               </li>
+                                       {/each}
+
+                                       {#if unpinnedConversations.length === 0}
+                                               <li class="px-2 py-4 text-center">
+                                                       <p class="mb-4 p-4 text-sm text-muted-foreground">
+                                                               {recentEmptyMessage}
+                                                       </p>
+                                               </li>
+                                       {/if}
+                               </ul>
+                       </div>
                </div>
-       </div>
-{/if}
+
+               {#if isSelectionMode}
+                       <SidebarNavigationSelectionBar
+                               class="sticky top-0 z-10 m-2 mt-0"
+                               selectedCount={selectedIds.size}
+                               {visibleCount}
+                               {allVisibleSelected}
+                               {someVisibleSelected}
+                               someSelectedPinned={allSelectedArePinned}
+                               {pinStateIsMixed}
+                               {onSelectAllToggle}
+                               {onBulkPinToggle}
+                               {onBulkExport}
+                               {onBulkDelete}
+                               onClose={onCloseSelection}
+                       />
+               {/if}
+       {/if}
+</div>
index 92d8fd0bda882f7e5b3121e7151fa46961084d3f..68d6c214366b03d5f50522055c159d2cabe6f2da 100644 (file)
@@ -7,10 +7,16 @@
                searchQuery: string;
                filteredConversations: DatabaseConversation[];
                currentChatId: string | undefined;
+               isSelectionMode?: boolean;
+               selectedIds?: Set<string>;
                onSelect: (id: string) => void;
                onEdit: (id: string) => void;
                onDelete: (id: string) => void;
                onStop: (id: string) => void;
+               onToggleSelect?: (id: string) => void;
+               onEnterSelectionMode?: (id: string) => void;
+               onSelectionClick?: (id: string, options: { shiftKey: boolean }) => void;
+               onRowMouseDown?: (id: string, event: MouseEvent) => void;
        }
 
        let {
                searchQuery,
                filteredConversations,
                currentChatId,
+               isSelectionMode = false,
+               selectedIds = new Set<string>(),
                onSelect,
                onEdit,
                onDelete,
-               onStop
+               onStop,
+               onToggleSelect,
+               onEnterSelectionMode,
+               onSelectionClick,
+               onRowMouseDown
        }: Props = $props();
 
        let tree = $derived(buildConversationTree(filteredConversations));
                                                }}
                                                {depth}
                                                isActive={currentChatId === conversation.id}
+                                               {isSelectionMode}
+                                               isSelected={selectedIds.has(conversation.id)}
                                                {onSelect}
                                                {onEdit}
                                                {onDelete}
                                                {onStop}
+                                               {onToggleSelect}
+                                               {onEnterSelectionMode}
+                                               {onSelectionClick}
+                                               {onRowMouseDown}
                                        />
                                </li>
                        {/each}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSelectionBar.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationSelectionBar.svelte
new file mode 100644 (file)
index 0000000..15412e5
--- /dev/null
@@ -0,0 +1,163 @@
+<script lang="ts">
+       import { Download, Pin, PinOff, Trash2, X } from '@lucide/svelte';
+       import { ActionIcon, DialogConfirmation } from '$lib/components/app';
+       import { Checkbox } from '$lib/components/ui/checkbox';
+       import { TooltipSide } from '$lib/enums';
+
+       interface Props {
+               class?: string;
+               selectedCount: number;
+               visibleCount: number;
+               allVisibleSelected: boolean;
+               someVisibleSelected: boolean;
+               someSelectedPinned: boolean;
+               pinStateIsMixed: boolean;
+               onSelectAllToggle: () => void;
+               onBulkPinToggle: () => void;
+               onBulkExport: () => void;
+               onBulkDelete: () => void;
+               onClose: () => void;
+       }
+
+       let {
+               class: className = '',
+               selectedCount,
+               visibleCount,
+               allVisibleSelected,
+               someVisibleSelected,
+               someSelectedPinned,
+               pinStateIsMixed,
+               onSelectAllToggle,
+               onBulkPinToggle,
+               onBulkExport,
+               onBulkDelete,
+               onClose
+       }: Props = $props();
+
+       let showDeleteDialog = $state(false);
+
+       function handleDeleteClick() {
+               showDeleteDialog = true;
+       }
+
+       function handleDeleteConfirm() {
+               showDeleteDialog = false;
+               onBulkDelete();
+       }
+
+       function handleDeleteCancel() {
+               showDeleteDialog = false;
+       }
+
+       const hasSelection = $derived(selectedCount > 0);
+       const isMasterChecked = $derived(allVisibleSelected);
+       const isMasterIndeterminate = $derived(!allVisibleSelected && someVisibleSelected);
+
+       const pinTooltip = $derived(
+               hasSelection
+                       ? pinStateIsMixed
+                               ? 'Unavailable for mixed state selection'
+                               : someSelectedPinned
+                                       ? selectedCount === 1
+                                               ? 'Unpin'
+                                               : 'Unpin all'
+                                       : selectedCount === 1
+                                               ? 'Pin'
+                                               : 'Pin all'
+                       : 'Pin'
+       );
+
+       const pinDisabled = $derived(!hasSelection || pinStateIsMixed);
+</script>
+
+<div
+       role="toolbar"
+       aria-label="Bulk actions for selected conversations"
+       class="flex items-center gap-1.5 rounded-xl border border-border/50 bg-background/50 px-2 py-1.5 shadow-sm backdrop-blur-xl {className}"
+>
+       <label class="flex min-w-0 cursor-pointer items-center gap-2">
+               <Checkbox
+                       checked={isMasterChecked}
+                       indeterminate={isMasterIndeterminate}
+                       onCheckedChange={onSelectAllToggle}
+                       aria-label={isMasterChecked ? 'Deselect all' : 'Select all'}
+               />
+
+               <span class="truncate text-xs font-medium text-muted-foreground">
+                       {selectedCount} / {visibleCount} selected
+               </span>
+       </label>
+
+       <div class="ml-auto flex items-center gap-0.75">
+               <ActionIcon
+                       icon={someSelectedPinned ? PinOff : Pin}
+                       tooltip={pinTooltip}
+                       tooltipSide={TooltipSide.TOP}
+                       disabled={pinDisabled}
+                       ariaLabel={pinTooltip}
+                       size="sm"
+                       iconSize="h-3.5 w-3.5"
+                       class="h-7 w-7 rounded-md bg-transparent backdrop-blur-none hover:bg-accent! {pinDisabled
+                               ? 'cursor-not-allowed'
+                               : ''} {!pinDisabled ? 'opacity-100' : 'opacity-40'}"
+                       onclick={onBulkPinToggle}
+               />
+
+               <ActionIcon
+                       icon={Download}
+                       tooltip={hasSelection ? 'Export' : 'Export'}
+                       tooltipSide={TooltipSide.TOP}
+                       disabled={!hasSelection}
+                       ariaLabel="Export selected"
+                       size="sm"
+                       iconSize="h-3.5 w-3.5"
+                       class="h-7 w-7 rounded-md bg-transparent backdrop-blur-none hover:bg-accent! {hasSelection
+                               ? 'opacity-100'
+                               : 'opacity-40'}"
+                       onclick={onBulkExport}
+               />
+
+               <ActionIcon
+                       icon={Trash2}
+                       tooltip="Delete selected"
+                       tooltipSide={TooltipSide.TOP}
+                       disabled={!hasSelection}
+                       ariaLabel="Delete selected"
+                       size="sm"
+                       iconSize="h-3.5 w-3.5 text-destructive"
+                       class="h-7 w-7 rounded-md bg-transparent backdrop-blur-none hover:bg-destructive/10! dark:hover:bg-destructive/20! disabled:hover:bg-transparent {hasSelection
+                               ? 'opacity-100'
+                               : 'opacity-40'}"
+                       onclick={handleDeleteClick}
+               />
+
+               <div class="mx-1 h-4 w-px bg-border" aria-hidden="true"></div>
+
+               <ActionIcon
+                       icon={X}
+                       tooltip="Exit bulk selection mode"
+                       tooltipSide={TooltipSide.TOP}
+                       ariaLabel="Exit bulk selection mode"
+                       size="sm"
+                       iconSize="h-3.5 w-3.5"
+                       class="h-7 w-7 rounded-md bg-transparent backdrop-blur-none hover:bg-accent!"
+                       onclick={onClose}
+               />
+       </div>
+</div>
+
+<DialogConfirmation
+       bind:open={showDeleteDialog}
+       title="Delete {selectedCount} conversation{selectedCount === 1 ? '' : 's'}"
+       description="This action cannot be undone. The selected conversation{selectedCount === 1
+               ? ''
+               : 's'} and {selectedCount === 1
+               ? 'its'
+               : 'their'} messages will be permanently removed, including any forks."
+       confirmText={selectedCount === 1 ? 'Delete' : `Delete ${selectedCount}`}
+       cancelText="Cancel"
+       variant="destructive"
+       icon={Trash2}
+       onConfirm={handleDeleteConfirm}
+       onCancel={handleDeleteCancel}
+/>
index e07dde6bc9012d6b69ba2bfc391c90c80545223b..ea5ad17940127fa130aab1596b9135dd2461e592 100644 (file)
@@ -114,6 +114,36 @@ export { default as SidebarNavigation } from './SidebarNavigation/SidebarNavigat
  */
 export { default as SidebarNavigationConversationItem } from './SidebarNavigation/SidebarNavigationConversationItem.svelte';
 
+/**
+ * **SidebarNavigationSelectionBar** - Bulk action toolbar for selection mode
+ *
+ * Rendered above the conversation list when the sidebar enters selection mode.
+ * Hosts a master checkbox (with select-all / clear-all semantics over the
+ * currently-visible items), a selected-count caption, and bulk actions for
+ * pin/unpin, export, and delete. Delete uses
+ * {@link DialogConfirmation} before invoking the bulk store method.
+ *
+ * Pure-presentational; all operations are delegated via callbacks so the
+ * sidebar owns selection state and persistence.
+ *
+ * @example
+ * ```svelte
+ * <SidebarNavigationSelectionBar
+ *   selectedCount={selectedIds.size}
+ *   visibleCount={visibleConversations.length}
+ *   allVisibleSelected={...}
+ *   someVisibleSelected={...}
+ *   someSelectedPinned={...}
+ *   onSelectAllToggle={toggleSelectAll}
+ *   onBulkPinToggle={handleBulkPin}
+ *   onBulkExport={handleBulkExport}
+ *   onBulkDelete={handleBulkDelete}
+ *   onClose={exitSelectionMode}
+ * />
+ * ```
+ */
+export { default as SidebarNavigationSelectionBar } from './SidebarNavigation/SidebarNavigationSelectionBar.svelte';
+
 /**
  * **SidebarNavigationConversationList** - Grouped conversation list
  *
index f5b968ae93fe187b1097012c34b8699e184d32ce..fa871cc98350720603414390d16111907d1f7de5 100644 (file)
@@ -4,6 +4,7 @@
        import { Checkbox } from '$lib/components/ui/checkbox';
        import { Input } from '$lib/components/ui/input';
        import Label from '$lib/components/ui/label/label.svelte';
+       import * as RadioGroup from '$lib/components/ui/radio-group';
        import * as Select from '$lib/components/ui/select';
        import { Textarea } from '$lib/components/ui/textarea';
        import { SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
                                                type={field.isPositiveInteger ? 'number' : 'text'}
                                                {...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
                                                value={currentValue}
-                                               oninput={(e) => {
-                                                       // Update local config immediately for real-time badge feedback
-                                                       onConfigChange(field.key, e.currentTarget.value);
-                                               }}
+                                               oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
                                                placeholder={currentModelParams[field.key] != null
                                                        ? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
                                                        : ''}
                                                {field.help || SETTING_CONFIG_INFO[field.key]}
                                        </p>
                                {/if}
+                       {:else if field.type === SettingsFieldType.RADIO && field.radioOptions}
+                               {@const radioOptions = field.radioOptions}
+                               {@const currentMode =
+                                       radioOptions.find((o: { key: string }) => Boolean(localConfig[o.key]))?.value ??
+                                       radioOptions[0].value}
+
+                               <Label class="flex items-center gap-1.5 text-sm font-medium mb-4">
+                                       {field.label}
+
+                                       {#if field.isExperimental}
+                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                       {/if}
+                               </Label>
+
+                               <RadioGroup.Root
+                                       class="gap-4"
+                                       value={currentMode}
+                                       onValueChange={(value) => {
+                                               for (const opt of radioOptions) {
+                                                       onConfigChange(opt.key, opt.value === value);
+                                               }
+                                       }}
+                               >
+                                       {#each radioOptions as opt (opt.value)}
+                                               {@const itemId = `${field.key}-${opt.value}`}
+                                               <div class="flex items-center gap-2">
+                                                       <RadioGroup.Item value={opt.value} id={itemId} />
+                                                       <Label
+                                                               for={itemId}
+                                                               class="flex cursor-pointer items-center gap-1.5 text-sm font-normal"
+                                                       >
+                                                               {opt.label}
+
+                                                               {#if opt.isExperimental}
+                                                                       <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                                               {/if}
+                                                       </Label>
+                                               </div>
+                                       {/each}
+                               </RadioGroup.Root>
+
+                               {#if field.help || SETTING_CONFIG_INFO[field.key]}
+                                       <p class="text-xs text-muted-foreground">
+                                               {field.help || SETTING_CONFIG_INFO[field.key]}
+                                       </p>
+                               {/if}
                        {:else if field.type === SettingsFieldType.CHECKBOX}
                                <div class="flex items-start space-x-3">
                                        <Checkbox
index c6d03b41c1a3400363f18b67ab665760ee36a9e8..404752f69bd4dc6fe595cf353331ba277338320e 100644 (file)
@@ -7,6 +7,8 @@
        import { toolsStore } from '$lib/stores/tools.svelte';
        import { permissionsStore } from '$lib/stores/permissions.svelte';
        import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
+       import { ToolSource } from '$lib/enums/tools.enums';
        import { SvelteSet } from 'svelte/reactivity';
 
        let expandedGroups = new SvelteSet<string>();
 
                                                {#each group.tools as entry (entry.key)}
                                                        {@const toolName = entry.definition.function.name}
+                                                       {@const builtinUi =
+                                                               entry.source === ToolSource.BUILTIN || entry.source === ToolSource.FRONTEND
+                                                                       ? getBuiltinToolUi(toolName)
+                                                                       : null}
+                                                       {@const displayLabel = builtinUi?.label ?? toolName}
+                                                       {@const IconComponent = builtinUi?.icon ?? null}
                                                        {@const isEnabled = toolsStore.isToolEnabled(entry.key)}
                                                        {@const permissionKey = entry.key}
                                                        {@const isAlwaysAllowed = permissionsStore.hasTool(permissionKey)}
 
                                                        <div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
-                                                               <TruncatedText text={toolName} class="flex-1" showTooltip={true} />
+                                                               <span class="flex min-w-0 flex-1 items-center gap-1.5">
+                                                                       {#if IconComponent}
+                                                                               <IconComponent class={ICON_CLASS_DEFAULT} />
+                                                                       {/if}
+                                                                       <TruncatedText text={displayLabel} class="min-w-0" showTooltip={true} />
+                                                               </span>
 
                                                                <div class="flex w-16 shrink-0 justify-center">
                                                                        <Checkbox
index aafa071881701a544fad2a45ea959a64b46ee55b..0eded6810ecb405b4b25079a4d60c6585bc218a6 100644 (file)
@@ -17,7 +17,7 @@
        bind:ref
        data-slot="checkbox"
        class={cn(
-               'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
+               'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
                className
        )}
        bind:checked
diff --git a/tools/ui/src/lib/components/ui/radio-group/index.ts b/tools/ui/src/lib/components/ui/radio-group/index.ts
new file mode 100644 (file)
index 0000000..a8bb80f
--- /dev/null
@@ -0,0 +1,10 @@
+import Root from './radio-group.svelte';
+import Item from './radio-group-item.svelte';
+
+export {
+       Root,
+       Item,
+       //
+       Root as RadioGroup,
+       Item as RadioGroupItem
+};
diff --git a/tools/ui/src/lib/components/ui/radio-group/radio-group-item.svelte b/tools/ui/src/lib/components/ui/radio-group/radio-group-item.svelte
new file mode 100644 (file)
index 0000000..af3ab61
--- /dev/null
@@ -0,0 +1,31 @@
+<script lang="ts">
+       import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
+       import CircleIcon from '@lucide/svelte/icons/circle';
+       import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               ...restProps
+       }: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
+</script>
+
+<RadioGroupPrimitive.Item
+       bind:ref
+       data-slot="radio-group-item"
+       class={cn(
+               'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
+               className
+       )}
+       {...restProps}
+>
+       {#snippet children({ checked })}
+               <div data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
+                       {#if checked}
+                               <CircleIcon
+                                       class="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
+                               />
+                       {/if}
+               </div>
+       {/snippet}
+</RadioGroupPrimitive.Item>
diff --git a/tools/ui/src/lib/components/ui/radio-group/radio-group.svelte b/tools/ui/src/lib/components/ui/radio-group/radio-group.svelte
new file mode 100644 (file)
index 0000000..083a0d4
--- /dev/null
@@ -0,0 +1,19 @@
+<script lang="ts">
+       import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
+       import { cn } from '$lib/components/ui/utils.js';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               value = $bindable(''),
+               ...restProps
+       }: RadioGroupPrimitive.RootProps = $props();
+</script>
+
+<RadioGroupPrimitive.Root
+       bind:ref
+       bind:value
+       data-slot="radio-group"
+       class={cn('grid gap-2 w-full', className)}
+       {...restProps}
+/>
index bbcdde43516fcd7ca967e628213935cfb4761f96..fab343659d9b56978e233a40edecd2895c47c151 100644 (file)
@@ -14,7 +14,6 @@ export const SETTINGS_KEYS = {
        SEND_ON_ENTER: 'sendOnEnter',
        ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
        PDF_AS_IMAGE: 'pdfAsImage',
-       ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
        TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
        TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM',
        TITLE_GENERATION_PROMPT: 'titleGenerationPrompt',
@@ -67,8 +66,8 @@ export const SETTINGS_KEYS = {
        DISABLE_REASONING_PARSING: 'disableReasoningParsing',
        EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
        SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
-       // PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
        JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
+       // PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
        CUSTOM_JSON: 'customJson',
        CUSTOM_CSS: 'customCss'
 } as const;
index 03d4ce4e9eb52d300d042ce30fe7db7c5c67aa83..f511b751de9f3c54b59971936d133605c99c10aa 100644 (file)
@@ -54,6 +54,34 @@ const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component
        { value: ColorMode.DARK, label: 'Dark', icon: Moon }
 ];
 
+// Shared options for the title-generation radio group. Both paired registry entries
+// (USE_FIRST_LINE, USE_LLM) reference this list so labels stay in lockstep.
+const TITLE_GENERATION_RADIO_OPTIONS: Array<{
+       value: string;
+       label: string;
+       key: string;
+       isExperimental?: boolean;
+}> = [
+       {
+               value: 'firstLine',
+               label: 'Use first non-empty line for the conversation title',
+               key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE
+       },
+       {
+               value: 'llm',
+               label: 'Generate title with LLM',
+               key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
+               isExperimental: true
+       }
+];
+
+// Common shape for the conversation title radio entry.
+const TITLE_GENERATION_BASE = {
+       type: SettingsFieldType.RADIO,
+       section: SETTINGS_SECTION_SLUGS.GENERAL,
+       radioOptions: TITLE_GENERATION_RADIO_OPTIONS
+} as const;
+
 const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
        [SETTINGS_SECTION_SLUGS.GENERAL]: {
                title: SETTINGS_SECTION_TITLES.GENERAL,
@@ -115,14 +143,15 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                }
                        },
                        {
-                               key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
-                               label: 'Copy text attachments as plain text',
-                               help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
+                               key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
+                               label: 'Show microphone on empty input',
+                               help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
                                defaultValue: false,
                                type: SettingsFieldType.CHECKBOX,
                                section: SETTINGS_SECTION_SLUGS.GENERAL,
+                               isExperimental: true,
                                sync: {
-                                       serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
+                                       serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
@@ -140,66 +169,53 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                }
                        },
                        {
-                               key: SETTINGS_KEYS.PDF_AS_IMAGE,
-                               label: 'Parse PDF as image',
-                               help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
-                               defaultValue: false,
-                               type: SettingsFieldType.CHECKBOX,
-                               section: SETTINGS_SECTION_SLUGS.GENERAL,
+                               ...TITLE_GENERATION_BASE,
+                               key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
+                               label: 'Conversation title',
+                               help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
+                               defaultValue: true,
                                sync: {
-                                       serverKey: SETTINGS_KEYS.PDF_AS_IMAGE,
+                                       serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
                        {
-                               key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
-                               label: 'Ask for confirmation before changing conversation title',
-                               help: 'Ask for confirmation before automatically changing conversation title when editing the first message.',
-                               defaultValue: false,
-                               type: SettingsFieldType.CHECKBOX,
+                               key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
+                               label: 'LLM title generation prompt',
+                               help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
+                               defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
+                               type: SettingsFieldType.TEXTAREA,
                                section: SETTINGS_SECTION_SLUGS.GENERAL,
+                               dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
                                sync: {
-                                       serverKey: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
-                                       paramType: SyncableParameterType.BOOLEAN
+                                       serverKey: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
+                                       paramType: SyncableParameterType.STRING
                                }
                        },
                        {
-                               key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
-                               label: 'Use first non-empty line for conversation title',
-                               help: 'Use only the first non-empty line of the prompt to generate the conversation title.',
+                               key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
+                               label: 'Copy text attachments as plain text',
+                               help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
                                defaultValue: false,
                                type: SettingsFieldType.CHECKBOX,
                                section: SETTINGS_SECTION_SLUGS.GENERAL,
                                sync: {
-                                       serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
+                                       serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
                        {
-                               key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
-                               label: 'Use LLM to generate conversation title',
-                               help: 'Use the LLM to automatically generate conversation titles based on the first message exchange.',
+                               key: SETTINGS_KEYS.PDF_AS_IMAGE,
+                               label: 'Parse PDF as image',
+                               help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
                                defaultValue: false,
                                type: SettingsFieldType.CHECKBOX,
                                section: SETTINGS_SECTION_SLUGS.GENERAL,
-                               isExperimental: true,
                                sync: {
-                                       serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
+                                       serverKey: SETTINGS_KEYS.PDF_AS_IMAGE,
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
-                       {
-                               key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
-                               label: 'LLM title generation prompt',
-                               help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
-                               defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
-                               type: SettingsFieldType.TEXTAREA,
-                               section: SETTINGS_SECTION_SLUGS.GENERAL,
-                               sync: {
-                                       serverKey: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
-                                       paramType: SyncableParameterType.STRING
-                               }
-                       },
                        {
                                key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
                                label: 'Maximum image resolution (megapixels)',
@@ -264,19 +280,6 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
-                       {
-                               key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
-                               label: 'Show microphone on empty input',
-                               help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
-                               defaultValue: false,
-                               type: SettingsFieldType.CHECKBOX,
-                               section: SETTINGS_SECTION_SLUGS.DISPLAY,
-                               isExperimental: true,
-                               sync: {
-                                       serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
-                                       paramType: SyncableParameterType.BOOLEAN
-                               }
-                       },
                        {
                                key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
                                label: 'Render user content as Markdown',
@@ -764,6 +767,17 @@ const NON_UI_SETTINGS: SettingsEntry[] = [
                defaultValue: '[]',
                type: SettingsFieldType.INPUT,
                sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING }
+       },
+       {
+               key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
+               label: 'Generate title with LLM',
+               help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.',
+               defaultValue: false,
+               type: SettingsFieldType.CHECKBOX,
+               sync: {
+                       serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
+                       paramType: SyncableParameterType.BOOLEAN
+               }
        }
        // {
        //      key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
@@ -812,7 +826,8 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
                        isPositiveInteger: s.isPositiveInteger,
                        dependsOn: s.dependsOn,
                        help: s.help,
-                       options: s.options
+                       options: s.options,
+                       radioOptions: s.radioOptions
                }))
        })),
        ...STANDALONE_SECTIONS
index f17f219762014bf9b24c3627e3f65991ef8eb339..6e0ebbd8015686fa0453f9c2175a7bead1711924 100644 (file)
@@ -22,5 +22,6 @@ export enum SettingsFieldType {
        INPUT = 'input',
        TEXTAREA = 'textarea',
        CHECKBOX = 'checkbox',
-       SELECT = 'select'
+       SELECT = 'select',
+       RADIO = 'radio'
 }
diff --git a/tools/ui/src/lib/hooks/use-marquee-selection.svelte.ts b/tools/ui/src/lib/hooks/use-marquee-selection.svelte.ts
new file mode 100644 (file)
index 0000000..c0bede1
--- /dev/null
@@ -0,0 +1,239 @@
+/**
+ * Reusable selection state-machine: shift+click/shift+drag range select plus
+ * rubber-band marquee drag, anchored on the last clicked row. Both the sidebar
+ * conversation list and the dialog conversations table share this.
+ *
+ * The hook mutates the consumer's SvelteSet<string> directly; the consumer
+ * owns the source of truth and reads it like any other $state. orderedIds
+ * must reflect the current visual order of selectable rows so the range
+ * matches what the user sees on screen.
+ */
+
+import { SvelteSet } from 'svelte/reactivity';
+
+interface UseMarqueeSelectionOptions {
+       /** Latest selected-IDs set. Re-read per selection event so consumer-side reassignment works. */
+       selectedIds: () => SvelteSet<string>;
+       /** IDs in the current rendered order; used to compute shift+click ranges and gate marquee visibility. */
+       orderedIds: () => string[];
+       /** Document listeners attach only while the getter returns true. */
+       enabled: () => boolean;
+       /** DOM attribute key (after the `data-` prefix) that marks selectable rows. */
+       attributeName?: () => string;
+       /** Minimum pixel distance before a press becomes a marquee drag. */
+       dragThresholdPx?: number;
+}
+
+export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
+       const dragThresholdPx = options.dragThresholdPx ?? 5;
+
+       let dragAnchorId = $state<string | null>(null);
+       let isMarqueeDragging = $state(false);
+       let mouseDownActive = false;
+       let dragStartX = 0;
+       let dragStartY = 0;
+       let mousedownRowId: string | null = null;
+       let dragMode: 'add' | 'remove' | null = null;
+       let suppressNextClick = false;
+
+       function resolveAttributeName(): string {
+               return options.attributeName?.() ?? 'conversation-row';
+       }
+
+       /**
+        * `dataset` keys are camelCased. `data-conversation-row` -> `conversationRow`.
+        * We resolve the attribute name once per call and read via the camelCase key.
+        */
+       function datasetKey(key: string = resolveAttributeName()): string {
+               return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+       }
+
+       function decideDragMode(startingRowId: string | null, currentlySelected: ReadonlySet<string>) {
+               return startingRowId !== null && currentlySelected.has(startingRowId) ? 'remove' : 'add';
+       }
+
+       /**
+        * Range-select uses Finder-style toggle-by-target semantics: if the target
+        * row is currently selected the range becomes deselected, otherwise it
+        * becomes selected. Anchor moves to `toId` so chained shift+clicks keep
+        * extending from the previous endpoint.
+        */
+       function rangeSelect(fromId: string, toId: string) {
+               const selected = options.selectedIds();
+               const order = options.orderedIds();
+               const fromIdx = order.indexOf(fromId);
+               const toIdx = order.indexOf(toId);
+               if (fromIdx === -1 || toIdx === -1) return;
+               const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
+               const shouldSelect = !selected.has(toId);
+               for (let i = lo; i <= hi; i++) {
+                       const id = order[i];
+                       if (shouldSelect) selected.add(id);
+                       else selected.delete(id);
+               }
+       }
+
+       function findRowAtPoint(x: number, y: number): string | null {
+               const attr = resolveAttributeName();
+               const selector = `[data-${attr}]`;
+               const key = datasetKey(attr);
+               let bestMatch: HTMLElement | null = null;
+               let bestCenterDistance = Infinity;
+
+               for (const row of document.querySelectorAll<HTMLElement>(selector)) {
+                       const rect = row.getBoundingClientRect();
+                       if (y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right) {
+                               return row.dataset[key] ?? null;
+                       }
+                       if (x >= rect.left && x <= rect.right) {
+                               const centerDistance = Math.abs(y - (rect.top + rect.height / 2));
+                               if (centerDistance < bestCenterDistance) {
+                                       bestCenterDistance = centerDistance;
+                                       bestMatch = row;
+                               }
+                       }
+               }
+               return bestMatch ? (bestMatch.dataset[key] ?? null) : null;
+       }
+
+       function updateMarqueeRect(currentX: number, currentY: number) {
+               const attr = resolveAttributeName();
+               const selector = `[data-${attr}]`;
+               const key = datasetKey(attr);
+               const selected = options.selectedIds();
+               const left = Math.min(dragStartX, currentX);
+               const top = Math.min(dragStartY, currentY);
+               const right = Math.max(dragStartX, currentX);
+               const bottom = Math.max(dragStartY, currentY);
+               const visibleIds = new SvelteSet(options.orderedIds());
+
+               for (const row of document.querySelectorAll<HTMLElement>(selector)) {
+                       const id = row.dataset[key];
+                       if (!id || !visibleIds.has(id)) continue;
+
+                       const rect = row.getBoundingClientRect();
+                       const intersects = !(
+                               rect.right < left ||
+                               rect.left > right ||
+                               rect.bottom < top ||
+                               rect.top > bottom
+                       );
+
+                       if (dragMode === 'add') {
+                               if (intersects) selected.add(id);
+                       } else if (dragMode === 'remove') {
+                               if (intersects && selected.has(id)) selected.delete(id);
+                       }
+               }
+       }
+
+       function handleDocumentMouseMove(event: MouseEvent) {
+               if (!mouseDownActive) return;
+
+               if (event.shiftKey && dragAnchorId !== null) {
+                       const target = findRowAtPoint(event.clientX, event.clientY);
+                       if (target && target !== mousedownRowId) rangeSelect(dragAnchorId, target);
+                       return;
+               }
+
+               if (!isMarqueeDragging) {
+                       const dx = event.clientX - dragStartX;
+                       const dy = event.clientY - dragStartY;
+                       if (Math.hypot(dx, dy) < dragThresholdPx) return;
+                       isMarqueeDragging = true;
+                       dragMode = decideDragMode(mousedownRowId, options.selectedIds());
+               }
+               updateMarqueeRect(event.clientX, event.clientY);
+       }
+
+       function handleDocumentMouseUp(event: MouseEvent) {
+               if (isMarqueeDragging) {
+                       suppressNextClick = true;
+                       const target = findRowAtPoint(event.clientX, event.clientY);
+                       if (target) dragAnchorId = target;
+               }
+               isMarqueeDragging = false;
+               mouseDownActive = false;
+               mousedownRowId = null;
+               dragMode = null;
+               dragStartX = 0;
+               dragStartY = 0;
+       }
+
+       function handleClickCapture(event: MouseEvent) {
+               if (suppressNextClick) {
+                       event.stopPropagation();
+                       event.preventDefault();
+                       suppressNextClick = false;
+               }
+       }
+
+       $effect(() => {
+               if (!options.enabled()) {
+                       reset();
+                       return;
+               }
+               document.addEventListener('mousemove', handleDocumentMouseMove);
+               document.addEventListener('mouseup', handleDocumentMouseUp);
+               document.addEventListener('click', handleClickCapture, { capture: true });
+               return () => {
+                       document.removeEventListener('mousemove', handleDocumentMouseMove);
+                       document.removeEventListener('mouseup', handleDocumentMouseUp);
+                       document.removeEventListener('click', handleClickCapture, { capture: true });
+               };
+       });
+
+       function rowMouseDown(id: string, event: MouseEvent) {
+               if (!options.enabled()) return;
+               if (event.button !== 0) return;
+               event.preventDefault();
+               mouseDownActive = true;
+               mousedownRowId = id;
+               dragStartX = event.clientX;
+               dragStartY = event.clientY;
+               isMarqueeDragging = false;
+               dragMode = null;
+       }
+
+       function rowClick(id: string, shiftKey: boolean) {
+               if (!options.enabled()) return;
+               const selected = options.selectedIds();
+
+               if (shiftKey) {
+                       const anchor = dragAnchorId;
+                       if (anchor !== null && anchor !== id) {
+                               rangeSelect(anchor, id);
+                       } else if (selected.has(id)) {
+                               selected.delete(id);
+                       } else {
+                               selected.add(id);
+                       }
+                       dragAnchorId = id;
+                       return;
+               }
+
+               if (selected.has(id)) selected.delete(id);
+               else selected.add(id);
+               dragAnchorId = id;
+       }
+
+       function reset() {
+               dragAnchorId = null;
+               isMarqueeDragging = false;
+               mouseDownActive = false;
+               suppressNextClick = false;
+               mousedownRowId = null;
+               dragMode = null;
+               dragStartX = 0;
+               dragStartY = 0;
+       }
+
+       return {
+               rowMouseDown,
+               rowClick,
+               reset,
+               get dragAnchorId() {
+                       return dragAnchorId;
+               }
+       };
+}
index 3338439bbec1fd0a2b51259b019b95ae14991fee..4fb70e29af7acabb756f7492bda24bbc3c371e5b 100644 (file)
@@ -3,6 +3,7 @@ import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
 import { IDXDB_TABLES, IDXDB_STORES, STORAGE_APP_NAME } from '$lib/constants';
 import { MessageRole } from '$lib/enums';
 import type { McpServerOverride } from '$lib/types/database';
+import type { ExportedConversation } from '$lib/types/database';
 
 class LlamaUiDatabase extends Dexie {
        [IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
@@ -206,18 +207,7 @@ export class DatabaseService {
                                                await db[IDXDB_TABLES.messages].where('convId').equals(forkId).delete();
                                        }
                                } else {
-                                       // Reparent direct children to deleted conv's parent
-                                       const conv = await db[IDXDB_TABLES.conversations].get(id);
-                                       const newParent = conv?.forkedFromConversationId;
-                                       const directChildren = await db[IDXDB_TABLES.conversations]
-                                               .filter((c) => c.forkedFromConversationId === id)
-                                               .toArray();
-
-                                       for (const child of directChildren) {
-                                               await db[IDXDB_TABLES.conversations].update(child.id, {
-                                                       forkedFromConversationId: newParent ?? undefined
-                                               });
-                                       }
+                                       await this.reparentDirectChildren(id);
                                }
 
                                await db[IDXDB_TABLES.conversations].delete(id);
@@ -226,6 +216,101 @@ export class DatabaseService {
                );
        }
 
+       /**
+        * Reparents direct children of `parentId` to the nearest surviving
+        * ancestor (or promotes them to top-level when the immediate parent was
+        * top-level). Walking skips any ancestor listed in `excludeIds`, since
+        * those will be deleted in the same batch â€” leaving a grandchild pointing
+        * at an `excludeIds` entry would orphan it. Children whose own id is in
+        * `excludeIds` are dropped from the updates (the bulk-delete pass will
+        * remove them). `prefetched` may carry a pre-fetched ancestor map to
+        * avoid repeat reads inside a bulk transaction.
+        */
+       private static async reparentDirectChildren(
+               parentId: string,
+               excludeIds: ReadonlySet<string> = new Set(),
+               prefetched?: ReadonlyMap<string, DatabaseConversation>
+       ): Promise<void> {
+               const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
+               if (!conv) return;
+
+               let newParent = conv.forkedFromConversationId;
+               const visited = new Set<string>([parentId]);
+               while (newParent && excludeIds.has(newParent)) {
+                       if (visited.has(newParent)) {
+                               newParent = undefined;
+                               break;
+                       }
+                       visited.add(newParent);
+                       const next =
+                               prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
+                       if (!next) {
+                               newParent = undefined;
+                               break;
+                       }
+                       newParent = next.forkedFromConversationId;
+               }
+
+               const directChildren = await db[IDXDB_TABLES.conversations]
+                       .filter((c) => c.forkedFromConversationId === parentId)
+                       .toArray();
+
+               const updates: DatabaseConversation[] = [];
+               for (const child of directChildren) {
+                       if (excludeIds.has(child.id)) continue;
+                       updates.push({ ...child, forkedFromConversationId: newParent });
+               }
+               if (updates.length === 0) return;
+               await db[IDXDB_TABLES.conversations].bulkPut(updates);
+       }
+
+       /**
+        * Deletes multiple conversations in a single transaction. Each deleted
+        * conversation has its direct children reparented to the nearest surviving
+        * ancestor (or promoted to top-level). Children also in `ids` are dropped
+        * entirely rather than reparented.
+        *
+        * @param ids - Conversation IDs to delete
+        */
+       static async bulkDeleteConversations(ids: string[]): Promise<void> {
+               const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
+               if (cleanIds.length === 0) return;
+               const idSet = new Set(cleanIds);
+
+               await db.transaction(
+                       'rw',
+                       [db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
+                       async () => {
+                               // Pre-load each to-delete conversation so the per-id reparent
+                               // walk-up doesn't ping-pong the same ancestry chain.
+                               const prefetched = new Map<string, DatabaseConversation>();
+                               let frontier = [...cleanIds];
+                               const requested = new Set<string>(frontier);
+                               while (frontier.length > 0) {
+                                       const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
+                                       frontier = [];
+                                       for (let i = 0; i < fetched.length; i++) {
+                                               const conv = fetched[i];
+                                               if (!conv || !conv.id) continue;
+                                               prefetched.set(conv.id, conv);
+                                               const ancestor = conv.forkedFromConversationId;
+                                               if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
+                                                       frontier.push(ancestor);
+                                                       requested.add(ancestor);
+                                               }
+                                       }
+                               }
+
+                               for (const id of cleanIds) {
+                                       await this.reparentDirectChildren(id, idSet, prefetched);
+                               }
+
+                               await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
+                               await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
+                       }
+               );
+       }
+
        /**
         * Deletes a message and removes it from its parent's children array.
         *
@@ -319,6 +404,43 @@ export class DatabaseService {
                return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
        }
 
+       /**
+        * Loads multiple conversations with all of their messages in two bulk
+        * reads. Missing conversations are silently omitted from the result.
+        *
+        * @param convIds - Conversation IDs to load
+        * @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
+        */
+       static async getConversationsWithMessages(
+               convIds: string[]
+       ): Promise<Map<string, ExportedConversation>> {
+               const result = new Map<string, ExportedConversation>();
+               const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
+               if (cleanIds.length === 0) return result;
+
+               const [convs, allMessages] = await Promise.all([
+                       db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
+                       db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
+               ]);
+
+               const messagesByConv = new Map<string, DatabaseMessage[]>();
+               for (const msg of allMessages) {
+                       const bucket = messagesByConv.get(msg.convId);
+                       if (bucket) bucket.push(msg);
+                       else messagesByConv.set(msg.convId, [msg]);
+               }
+
+               for (let i = 0; i < cleanIds.length; i++) {
+                       const conv = convs[i];
+                       if (!conv) continue;
+                       const messages = (messagesByConv.get(conv.id) ?? []).sort(
+                               (a, b) => a.timestamp - b.timestamp
+                       );
+                       result.set(conv.id, { conv, messages });
+               }
+               return result;
+       }
+
        /**
         * Updates a conversation.
         *
@@ -360,6 +482,38 @@ export class DatabaseService {
                return newPinnedState;
        }
 
+       /**
+        * Toggles the pinned status of each conversation in `ids` inside a single
+        * transaction. Treats `pinned === undefined` as `false`, matching the
+        * semantics of {@link toggleConversationPin} where `!undefined` evaluates
+        * to `true`. Returns the resulting pinned state for every id that was
+        * updated; missing ids are omitted from the map.
+        *
+        * @param ids - Conversation IDs to toggle
+        * @returns Map of id -> new pinned state
+        */
+       static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
+               const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
+               const result = new Map<string, boolean>();
+               if (cleanIds.length === 0) return result;
+
+               const now = Date.now();
+               await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
+                       const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
+                       const updates: DatabaseConversation[] = [];
+                       for (let i = 0; i < cleanIds.length; i++) {
+                               const conv = convs[i];
+                               if (!conv) continue;
+                               const newPinned = !conv.pinned;
+                               updates.push({ ...conv, pinned: newPinned, lastModified: now });
+                               result.set(cleanIds[i], newPinned);
+                       }
+                       if (updates.length === 0) return;
+                       await db[IDXDB_TABLES.conversations].bulkPut(updates);
+               });
+               return result;
+       }
+
        /**
         * Updates the conversation's current node (active branch).
         * This determines which conversation path is currently being viewed.
index 40c9cf71fac0a0b61b3741e32ba27b05fe9eddc3..658eb5978cfc5afa339613e8c4d8c0807aeaf24d 100644 (file)
@@ -1596,7 +1596,7 @@ class ChatStore {
                        conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
                        await DatabaseService.updateMessage(messageId, { content: newContent });
                        if (isFirstUserMessage && newContent.trim())
-                               await conversationsStore.updateConversationTitleWithConfirmation(
+                               await conversationsStore.updateConversationName(
                                        activeConv.id,
                                        generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
                                );
@@ -2113,7 +2113,7 @@ class ChatStore {
                        const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
 
                        if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
-                               await conversationsStore.updateConversationTitleWithConfirmation(
+                               await conversationsStore.updateConversationName(
                                        activeConv.id,
                                        generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
                                );
@@ -2187,7 +2187,7 @@ class ChatStore {
 
                        conversationsStore.updateConversationTimestamp();
                        if (isFirstUserMessage && newContent.trim())
-                               await conversationsStore.updateConversationTitleWithConfirmation(
+                               await conversationsStore.updateConversationName(
                                        activeConv.id,
                                        generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
                                );
index c7bfd00b8c16b2997dd98580594d4624e429041c..5c54e72ace9cd8a3ca22b9517ae701c158ca5a27 100644 (file)
@@ -108,9 +108,6 @@ class ConversationsStore {
                localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
        }
 
-       /** Callback for title update confirmation dialog */
-       titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
-
        /**
         * Callback for updating message content in chatStore.
         * Registered by chatStore to enable cross-store updates without circular dependency.
@@ -209,15 +206,6 @@ class ConversationsStore {
                return undefined;
        }
 
-       /**
-        * Sets the callback function for title update confirmations
-        */
-       setTitleUpdateConfirmationCallback(
-               callback: (currentTitle: string, newTitle: string) => Promise<boolean>
-       ): void {
-               this.titleUpdateConfirmationCallback = callback;
-       }
-
        /**
         *
         *
@@ -386,6 +374,123 @@ class ConversationsStore {
                }
        }
 
+       /**
+        * Deletes multiple conversations in sequence.
+        * Mirrors deleteConversation() per-id; navigates to NEW_CHAT only if the
+        * currently-open chat was among the deleted ones.
+        * @param convIds - Conversation IDs to delete
+        */
+       async bulkDeleteConversations(convIds: string[]): Promise<void> {
+               if (convIds.length === 0) return;
+
+               try {
+                       const idsToRemove = new SvelteSet(convIds);
+                       // Collect all descendants recursively so the local cache stays consistent
+                       // even when deleteWithForks is omitted.
+                       const queue = [...convIds];
+                       while (queue.length > 0) {
+                               const parentId = queue.pop()!;
+                               for (const c of this.conversations) {
+                                       if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
+                                               idsToRemove.add(c.id);
+                                               queue.push(c.id);
+                                       }
+                               }
+                       }
+
+                       const activeWasDeleted =
+                               this.activeConversation !== null && idsToRemove.has(this.activeConversation.id);
+
+                       await DatabaseService.bulkDeleteConversations([...idsToRemove]);
+
+                       this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
+
+                       if (activeWasDeleted) {
+                               this.clearActiveConversation();
+                               await goto(ROUTES.NEW_CHAT);
+                       }
+
+                       toast.success(
+                               convIds.length === 1 ? 'Conversation deleted' : `${convIds.length} conversations deleted`
+                       );
+               } catch (error) {
+                       console.error('Failed to bulk delete conversations:', error);
+                       toast.error('Failed to delete conversations');
+               }
+       }
+
+       /**
+        * Toggles the pinned state of each conversation individually.
+        * Mixed-pin selections are intentionally not normalised here; the bulk
+        * action UI surfaces them as a disabled mixed-state instead.
+        * @param convIds - Conversation IDs to toggle
+        */
+       async bulkToggleConversationPin(convIds: string[]): Promise<void> {
+               if (convIds.length === 0) return;
+
+               try {
+                       const updates = await DatabaseService.bulkToggleConversationPins(convIds);
+
+                       const activeId = this.activeConversation?.id;
+                       if (activeId && updates.has(activeId)) {
+                               this.activeConversation = {
+                                       ...this.activeConversation!,
+                                       pinned: updates.get(activeId)!
+                               };
+                       }
+                       for (let i = 0; i < this.conversations.length; i++) {
+                               const newPinned = updates.get(this.conversations[i].id);
+                               if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
+                       }
+                       this.conversations = [...this.conversations];
+
+                       toast.success(
+                               convIds.length === 1
+                                       ? 'Conversation pin toggled'
+                                       : `Updated pin state for ${convIds.length} conversations`
+                       );
+               } catch (error) {
+                       console.error('Failed to bulk toggle pin:', error);
+                       toast.error('Failed to update pin state');
+               }
+       }
+
+       /**
+        * Bundles the given conversations into a single zip archive and triggers a
+        * browser download (one JSONL file per conversation).
+        * @param convIds - Conversation IDs to export
+        */
+       async bulkExportConversations(convIds: string[]): Promise<void> {
+               if (convIds.length === 0) return;
+
+               try {
+                       const fetched = await DatabaseService.getConversationsWithMessages(convIds);
+
+                       const activeId = this.activeConversation?.id;
+                       const overridden = fetched.get(activeId ?? '');
+                       if (overridden && activeId) {
+                               overridden.conv = { ...this.activeConversation! };
+                       }
+
+                       const exported = [...fetched.values()];
+                       if (exported.length === 0) {
+                               toast.error('No conversations to export');
+                               return;
+                       }
+
+                       this.downloadConversationsArchive(exported);
+
+                       toast.success(
+                               exported.length === 1
+                                       ? 'Conversation exported'
+                                       : `${exported.length} conversations exported`
+                       );
+               } catch (error) {
+                       console.error('Failed to bulk export conversations:', error);
+                       toast.error('Failed to export conversations');
+               }
+       }
+
        /**
         *
         *
@@ -484,38 +589,6 @@ class ConversationsStore {
                }
        }
 
-       /**
-        * Updates conversation title with optional confirmation dialog based on settings
-        * @param convId - The conversation ID to update
-        * @param newTitle - The new title content
-        * @returns True if title was updated, false if cancelled
-        */
-       async updateConversationTitleWithConfirmation(
-               convId: string,
-               newTitle: string
-       ): Promise<boolean> {
-               try {
-                       const currentConfig = config();
-
-                       if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
-                               const conversation = await DatabaseService.getConversation(convId);
-                               if (!conversation) return false;
-
-                               const shouldUpdate = await this.titleUpdateConfirmationCallback(
-                                       conversation.name,
-                                       newTitle
-                               );
-                               if (!shouldUpdate) return false;
-                       }
-
-                       await this.updateConversationName(convId, newTitle);
-                       return true;
-               } catch (error) {
-                       console.error('Failed to update conversation title with confirmation:', error);
-                       return false;
-               }
-       }
-
        /**
         * Updates conversation lastModified timestamp and moves it to top of list
         */
@@ -581,7 +654,7 @@ class ConversationsStore {
                                        newFirstUserMessage.id !== currentFirstUserMessage.id ||
                                        newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
                        ) {
-                               await this.updateConversationTitleWithConfirmation(
+                               await this.updateConversationName(
                                        this.activeConversation.id,
                                        generateConversationTitle(
                                                newFirstUserMessage.content,
@@ -1162,6 +1235,10 @@ export const isConversationsInitialized = () => conversationsStore.isInitialized
 /**
  * Builds a flat tree of conversations with depth levels for nested forks.
  * Accepts a pre-filtered list so search filtering stays in the component.
+ *
+ * Output order matches the sidebar render exactly: pinned first, then
+ * unpinned by lastModified desc, with forks interleaved under their parents.
+ * Range-select / marquee in the sidebar rely on this alignment.
  */
 
 // Pinned conversations first, then by lastModified descending
index 9b4af4a58f7d6a560208dc951c798f22176f4173..c38665de737a123ce51fdfcedc71227a6ec309cf 100644 (file)
@@ -27,6 +27,8 @@ export interface SettingsEntry {
        type: SettingsFieldType;
        section?: string;
        options?: Array<{ value: string; label: string; icon: Component }>;
+       /** Options rendered for RADIO fields. Each entry maps a `value` (the radio's selected value) to the underlying config `key` whose boolean state mirrors it. */
+       radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
        isExperimental?: boolean;
        isPositiveInteger?: boolean;
        dependsOn?: string;
@@ -53,6 +55,8 @@ export interface SettingsFieldConfig {
        dependsOn?: string;
        help?: string;
        options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
+       /** Options rendered for RADIO fields. Each entry maps a `value` (the radio's selected value) to the underlying config `key` whose boolean state mirrors it. */
+       radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
 }
 
 /** Re-exported for backward compatibility. */
index ba1c78fbb2ec28e1bd4d4585e4fee63e8b9b92b1..e25f141d1e1536cc5de5834d28dfb5820dff2836 100644 (file)
@@ -7,12 +7,11 @@
        import { untrack } from 'svelte';
        import { onMount } from 'svelte';
 
-       import { SidebarNavigation, DialogConversationTitleUpdate } from '$lib/components/app';
+       import { SidebarNavigation } from '$lib/components/app';
        import { PwaMetaTags, PwaRefreshAlert } from '$lib/components/pwa';
        import { pwaAssetsHead } from 'virtual:pwa-assets/head';
 
        import { chatStore } from '$lib/stores/chat.svelte';
-       import { conversationsStore } from '$lib/stores/conversations.svelte';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
        import { config, settingsStore } from '$lib/stores/settings.svelte';
 
        let showBuildVersion = $derived(config()[SETTINGS_KEYS.SHOW_BUILD_VERSION] as boolean);
 
-       let titleUpdateDialogOpen = $state(false);
-       let titleUpdateCurrentTitle = $state('');
-       let titleUpdateNewTitle = $state('');
-       let titleUpdateResolve: ((value: boolean) => void) | null = null;
-
        // Keep the hook object intact: destructuring needRefreshByStorage reads the getter once and freezes it
        const pwa = usePwa();
        const { needRefresh, updateServiceWorker } = pwa;
                });
        }
 
-       function handleTitleUpdateCancel() {
-               titleUpdateDialogOpen = false;
-
-               if (titleUpdateResolve) {
-                       titleUpdateResolve(false);
-                       titleUpdateResolve = null;
-               }
-       }
-
-       function handleTitleUpdateConfirm() {
-               titleUpdateDialogOpen = false;
-
-               if (titleUpdateResolve) {
-                       titleUpdateResolve(true);
-                       titleUpdateResolve = null;
-               }
-       }
-
        onMount(() => {
                updateFavicon();
                // snapshot of every backend running stream on first load, populates the sidebar spinners
        $effect(() => {
                checkApiKey();
        });
-
-       // Set up title update confirmation callback
-       $effect(() => {
-               conversationsStore.setTitleUpdateConfirmationCallback(
-                       async (currentTitle: string, newTitle: string) => {
-                               return new Promise<boolean>((resolve) => {
-                                       titleUpdateCurrentTitle = currentTitle;
-                                       titleUpdateNewTitle = newTitle;
-                                       titleUpdateResolve = resolve;
-                                       titleUpdateDialogOpen = true;
-                               });
-                       }
-               );
-       });
 </script>
 
 <svelte:head>
        <ModeWatcher />
 
        <Toaster richColors />
-
-       <DialogConversationTitleUpdate
-               bind:open={titleUpdateDialogOpen}
-               currentTitle={titleUpdateCurrentTitle}
-               newTitle={titleUpdateNewTitle}
-               onConfirm={handleTitleUpdateConfirm}
-               onCancel={handleTitleUpdateCancel}
-       />
 </Tooltip.Provider>
 
 <!-- PWA update prompt + version -->