"@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": {
"@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",
<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>
--- /dev/null
+<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>
<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'}
<ConversationSelection
bind:this={conversationSelectionRef}
+ isOpen={open}
{conversations}
{messageCountMap}
{mode}
+++ /dev/null
-<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>
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
+
<AlertDialog.Action
onclick={onConfirm}
class="bg-destructive text-white hover:bg-destructive/80"
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';
/**
*
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));
})
);
+ 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() {
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>
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';
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) {
: 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
}}
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;
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>
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}
--- /dev/null
+<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}
+/>
*/
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
*
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
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
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
--- /dev/null
+import Root from './radio-group.svelte';
+import Item from './radio-group-item.svelte';
+
+export {
+ Root,
+ Item,
+ //
+ Root as RadioGroup,
+ Item as RadioGroupItem
+};
--- /dev/null
+<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>
--- /dev/null
+<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}
+/>
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',
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;
{ 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,
}
},
{
- 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
}
},
}
},
{
- 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)',
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',
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,
isPositiveInteger: s.isPositiveInteger,
dependsOn: s.dependsOn,
help: s.help,
- options: s.options
+ options: s.options,
+ radioOptions: s.radioOptions
}))
})),
...STANDALONE_SECTIONS
INPUT = 'input',
TEXTAREA = 'textarea',
CHECKBOX = 'checkbox',
- SELECT = 'select'
+ SELECT = 'select',
+ RADIO = 'radio'
}
--- /dev/null
+/**
+ * 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;
+ }
+ };
+}
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>;
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);
);
}
+ /**
+ * 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.
*
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.
*
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.
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))
);
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))
);
conversationsStore.updateConversationTimestamp();
if (isFirstUserMessage && newContent.trim())
- await conversationsStore.updateConversationTitleWithConfirmation(
+ await conversationsStore.updateConversationName(
activeConv.id,
generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
);
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.
return undefined;
}
- /**
- * Sets the callback function for title update confirmations
- */
- setTitleUpdateConfirmationCallback(
- callback: (currentTitle: string, newTitle: string) => Promise<boolean>
- ): void {
- this.titleUpdateConfirmationCallback = callback;
- }
-
/**
*
*
}
}
+ /**
+ * 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');
+ }
+ }
+
/**
*
*
}
}
- /**
- * 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
*/
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
- await this.updateConversationTitleWithConfirmation(
+ await this.updateConversationName(
this.activeConversation.id,
generateConversationTitle(
newFirstUserMessage.content,
/**
* 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
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;
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. */
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 -->