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:
Aleksander Grygier
2026-07-20 23:40:08 +02:00
committed by GitHub
co-authored by Pascal
parent 91d2fc3875
commit 2beefef688
31 changed files with 1529 additions and 426 deletions
@@ -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>
@@ -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) {
@@ -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
}}
@@ -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;
@@ -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>
@@ -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}
@@ -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