mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-07-21 10:15:53 +00:00
ui: Sidebar Conversations Bulk Action + Improved Settings logic/UI (#25815)
* feat: WIP * feat: Replace conversation rename flow with unified AlertDialog component * feat: Add radio group component and consolidate title generation settings * refactor: Remove JS Sandbox global toggle and migrate legacy user state * chore: Formatting * refactor: Cleanup Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * 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 <admin@serveurperso.com>
This commit is contained in:
co-authored by
Pascal
parent
91d2fc3875
commit
2beefef688
Generated
+4
-4
@@ -12,7 +12,7 @@
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@lucide/svelte": "0.515.0",
|
||||
"@lucide/svelte": "1.25.0",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "10.2.4",
|
||||
@@ -3065,9 +3065,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "0.515.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.515.0.tgz",
|
||||
"integrity": "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA==",
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -66,7 +66,14 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
|
||||
|
||||
interface Props {
|
||||
conversations: DatabaseConversation[];
|
||||
@@ -11,13 +12,20 @@
|
||||
mode: 'export' | 'import';
|
||||
onCancel: () => void;
|
||||
onConfirm: (selectedConversations: DatabaseConversation[]) => void;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
|
||||
let {
|
||||
conversations,
|
||||
messageCountMap = new Map(),
|
||||
mode,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
isOpen = true
|
||||
}: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let selectedIds = $state.raw<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))
|
||||
@@ -39,54 +49,20 @@
|
||||
filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
|
||||
);
|
||||
|
||||
function toggleConversation(id: string, shiftKey: boolean = false) {
|
||||
const newSet = new SvelteSet(selectedIds);
|
||||
|
||||
if (shiftKey && lastClickedId !== null) {
|
||||
const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
|
||||
const currentIndex = filteredConversations.findIndex((c) => c.id === id);
|
||||
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
|
||||
const shouldSelect = !newSet.has(id);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (shouldSelect) {
|
||||
newSet.add(filteredConversations[i].id);
|
||||
} else {
|
||||
newSet.delete(filteredConversations[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
selectedIds = newSet;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
|
||||
selectedIds = newSet;
|
||||
lastClickedId = id;
|
||||
}
|
||||
const marquee = useMarqueeSelection({
|
||||
selectedIds: () => selectedIds,
|
||||
orderedIds: () => orderedIds,
|
||||
enabled: () => isOpen
|
||||
});
|
||||
|
||||
function toggleAll() {
|
||||
const newSet = new SvelteSet(selectedIds);
|
||||
if (allSelected) {
|
||||
const newSet = new SvelteSet(selectedIds);
|
||||
|
||||
filteredConversations.forEach((conv) => newSet.delete(conv.id));
|
||||
selectedIds = newSet;
|
||||
} else {
|
||||
const newSet = new SvelteSet(selectedIds);
|
||||
|
||||
filteredConversations.forEach((conv) => newSet.add(conv.id));
|
||||
selectedIds = newSet;
|
||||
}
|
||||
selectedIds = newSet;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
@@ -97,7 +73,7 @@
|
||||
function handleCancel() {
|
||||
selectedIds = getInitialSelectedIds();
|
||||
searchQuery = '';
|
||||
lastClickedId = null;
|
||||
marquee.reset();
|
||||
|
||||
onCancel();
|
||||
}
|
||||
@@ -105,7 +81,7 @@
|
||||
export function reset() {
|
||||
selectedIds = getInitialSelectedIds();
|
||||
searchQuery = '';
|
||||
lastClickedId = null;
|
||||
marquee.reset();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -122,7 +98,7 @@
|
||||
</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">
|
||||
@@ -139,6 +115,7 @@
|
||||
<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#if filteredConversations.length === 0}
|
||||
<tr>
|
||||
@@ -152,23 +129,28 @@
|
||||
</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>
|
||||
|
||||
+204
-47
@@ -4,15 +4,22 @@
|
||||
import { PanelLeftClose, PanelLeftOpen, X } from '@lucide/svelte';
|
||||
import {
|
||||
ActionIcon,
|
||||
DialogConversationRename,
|
||||
Logo,
|
||||
SidebarNavigationConversationList,
|
||||
SidebarNavigationActions
|
||||
} from '$lib/components/app';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
|
||||
|
||||
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||
import {
|
||||
buildConversationTree,
|
||||
conversationsStore,
|
||||
conversations
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
@@ -40,7 +47,6 @@
|
||||
const isOnMobile = $derived(isMobile.current);
|
||||
const alwaysShowOnDesktop = $derived(config().alwaysShowSidebarOnDesktop as boolean);
|
||||
|
||||
// Keep the sidebar expanded on desktop when the user pins it open
|
||||
$effect(() => {
|
||||
if (alwaysShowOnDesktop && !isOnMobile) {
|
||||
isExpandedMode = true;
|
||||
@@ -58,13 +64,11 @@
|
||||
if (!isExpandedMode) {
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
if (isSelectionMode) exitSelectionMode();
|
||||
cancelMobileCollapse();
|
||||
}
|
||||
});
|
||||
|
||||
// On mobile the dedicated /search route hides the sidebar (see the aside
|
||||
// render guard below). Collapse it as we enter /search so it doesn't
|
||||
// reappear expanded when the user navigates back via the back button.
|
||||
$effect(() => {
|
||||
if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) {
|
||||
isExpandedMode = false;
|
||||
@@ -89,6 +93,121 @@
|
||||
return conversations();
|
||||
});
|
||||
|
||||
let isSelectionMode = $state(false);
|
||||
let selectedIds = new SvelteSet<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();
|
||||
@@ -100,10 +219,30 @@
|
||||
const conversation = conversations().find((conv) => conv.id === id);
|
||||
if (!conversation) return;
|
||||
|
||||
const newName = window.prompt('Rename conversation', conversation.name);
|
||||
if (newName && newName.trim()) {
|
||||
await conversationsStore.updateConversationName(id, newName.trim());
|
||||
}
|
||||
renameTargetConversationId = id;
|
||||
renameOriginalTitle = conversation.name;
|
||||
renameDraft = conversation.name;
|
||||
renameDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleRenameConfirm() {
|
||||
const id = renameTargetConversationId;
|
||||
if (!id) return;
|
||||
|
||||
const nextName = renameDraft.trim();
|
||||
if (!nextName || nextName === renameOriginalTitle.trim()) return;
|
||||
|
||||
await conversationsStore.updateConversationName(id, nextName);
|
||||
|
||||
renameDialogOpen = false;
|
||||
renameTargetConversationId = null;
|
||||
}
|
||||
|
||||
function handleRenameCancel() {
|
||||
renameDialogOpen = false;
|
||||
renameTargetConversationId = null;
|
||||
renameDraft = '';
|
||||
renameOriginalTitle = '';
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(id: string) {
|
||||
@@ -148,9 +287,7 @@
|
||||
{#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))}
|
||||
<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
|
||||
@@ -158,17 +295,11 @@
|
||||
: 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'
|
||||
]}
|
||||
>
|
||||
@@ -218,52 +349,78 @@
|
||||
{/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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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}
|
||||
{#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) {
|
||||
|
||||
+4
-10
@@ -119,9 +119,7 @@
|
||||
: 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
|
||||
}}
|
||||
|
||||
@@ -140,10 +138,8 @@
|
||||
{@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>
|
||||
@@ -171,9 +167,7 @@
|
||||
: 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
|
||||
}}
|
||||
|
||||
|
||||
+84
-6
@@ -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';
|
||||
@@ -24,10 +26,16 @@
|
||||
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 {
|
||||
@@ -36,7 +44,13 @@
|
||||
onEdit,
|
||||
onSelect,
|
||||
onStop,
|
||||
onToggleSelect,
|
||||
onEnterSelectionMode,
|
||||
onSelectionClick,
|
||||
onRowMouseDown,
|
||||
isActive = false,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
depth = 0
|
||||
}: Props = $props();
|
||||
|
||||
@@ -64,6 +78,11 @@
|
||||
conversationsStore.toggleConversationPin(conversation.id);
|
||||
}
|
||||
|
||||
function handleEnterSelectionMode(event: Event) {
|
||||
event.stopPropagation();
|
||||
onEnterSelectionMode?.(conversation.id);
|
||||
}
|
||||
|
||||
function handleGlobalEditEvent(event: Event) {
|
||||
const customEvent = event as CustomEvent<{ conversationId: string }>;
|
||||
|
||||
@@ -79,11 +98,40 @@
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -108,10 +156,14 @@
|
||||
<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)) {
|
||||
@@ -123,6 +175,23 @@
|
||||
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>
|
||||
@@ -170,7 +239,7 @@
|
||||
<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}
|
||||
@@ -200,6 +269,11 @@
|
||||
},
|
||||
shortcut: ['shift', 'cmd', 's']
|
||||
},
|
||||
{
|
||||
icon: ListChecks,
|
||||
label: 'Select',
|
||||
onclick: handleEnterSelectionMode
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
@@ -230,6 +304,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selection-mode :global([data-slot='dropdown-menu-trigger']) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.stop-button {
|
||||
:global(.stop-icon) {
|
||||
display: none;
|
||||
|
||||
+137
-67
@@ -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;
|
||||
@@ -10,10 +11,26 @@
|
||||
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 {
|
||||
@@ -22,10 +39,26 @@
|
||||
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));
|
||||
@@ -43,65 +76,38 @@
|
||||
);
|
||||
</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={{
|
||||
@@ -114,22 +120,86 @@
|
||||
}}
|
||||
{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>
|
||||
|
||||
+19
-1
@@ -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 {
|
||||
@@ -18,10 +24,16 @@
|
||||
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));
|
||||
@@ -56,10 +68,16 @@
|
||||
}}
|
||||
{depth}
|
||||
isActive={currentChatId === conversation.id}
|
||||
{isSelectionMode}
|
||||
isSelected={selectedIds.has(conversation.id)}
|
||||
{onSelect}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onStop}
|
||||
{onToggleSelect}
|
||||
{onEnterSelectionMode}
|
||||
{onSelectionClick}
|
||||
{onRowMouseDown}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
+163
@@ -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}
|
||||
/>
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
@@ -84,10 +85,7 @@
|
||||
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])}`
|
||||
: ''}
|
||||
@@ -236,6 +234,52 @@
|
||||
{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
|
||||
|
||||
@@ -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>();
|
||||
@@ -69,12 +71,23 @@
|
||||
|
||||
{#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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,54 +169,16 @@ 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,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
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,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
...TITLE_GENERATION_BASE,
|
||||
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.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
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.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
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.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
isExperimental: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
|
||||
label: 'LLM title generation prompt',
|
||||
@@ -195,11 +186,36 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
|
||||
type: SettingsFieldType.TEXTAREA,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
|
||||
paramType: SyncableParameterType.STRING
|
||||
}
|
||||
},
|
||||
{
|
||||
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.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
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,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
@@ -22,5 +22,6 @@ export enum SettingsFieldType {
|
||||
INPUT = 'input',
|
||||
TEXTAREA = 'textarea',
|
||||
CHECKBOX = 'checkbox',
|
||||
SELECT = 'select'
|
||||
SELECT = 'select',
|
||||
RADIO = 'radio'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+4
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
@@ -46,11 +45,6 @@
|
||||
|
||||
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;
|
||||
@@ -135,24 +129,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
@@ -264,20 +240,6 @@
|
||||
$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>
|
||||
@@ -319,14 +281,6 @@
|
||||
<ModeWatcher />
|
||||
|
||||
<Toaster richColors />
|
||||
|
||||
<DialogConversationTitleUpdate
|
||||
bind:open={titleUpdateDialogOpen}
|
||||
currentTitle={titleUpdateCurrentTitle}
|
||||
newTitle={titleUpdateNewTitle}
|
||||
onConfirm={handleTitleUpdateConfirm}
|
||||
onCancel={handleTitleUpdateCancel}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<!-- PWA update prompt + version -->
|
||||
|
||||
Reference in New Issue
Block a user