]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: implement pinned conversations support (#21387)
authorRémy Mathieu <redacted>
Tue, 9 Jun 2026 19:33:22 +0000 (21:33 +0200)
committerGitHub <redacted>
Tue, 9 Jun 2026 19:33:22 +0000 (21:33 +0200)
* webui: implement pinned conversations support

* webui: linter/prettier pass

* Fix the unused handleMobileSidebarItemClick from the component.

* the search should find pinned conversations as well

Co-authored-by: Pascal <redacted>
---------

Co-authored-by: Pascal <redacted>
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
tools/ui/src/lib/services/database.service.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/types/database.d.ts

index ddaf4d5b8750267fc675296b10730c2a48302716..1fa7722f24062239930e1da930468faf39c84650 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
        import { goto } from '$app/navigation';
        import { page } from '$app/state';
-       import { Trash2, Pencil, X } from '@lucide/svelte';
+       import { Trash2, Pencil, Pin, X } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import { DialogConfirmation } from '$lib/components/app';
        import SidebarNavigationActions from './SidebarNavigationActions.svelte';
 
        let conversationTree = $derived(buildConversationTree(filteredConversations));
 
+       let pinnedConversations = $derived.by(() => {
+               return conversationTree.filter(({ conversation }) => conversation.pinned);
+       });
+
+       let unpinnedConversations = $derived.by(() => {
+               return conversationTree.filter(({ conversation }) => !conversation.pinned);
+       });
+
        let selectedConversationHasDescendants = $derived.by(() => {
                if (!selectedConversation) return false;
 
                        />
                </Sidebar.Header>
 
+               {#if !isSearchModeActive && pinnedConversations.length > 0}
+                       <Sidebar.Group class="p-0 px-4">
+                               <Sidebar.GroupLabel>
+                                       <div class="flex items-center gap-1">
+                                               <Pin class="h-3.5 w-3.5" />
+                                               <span>Pinned</span>
+                                       </div>
+                               </Sidebar.GroupLabel>
+                               <Sidebar.GroupContent>
+                                       <Sidebar.Menu>
+                                               {#each pinnedConversations as { conversation, depth } (conversation.id)}
+                                                       <Sidebar.MenuItem class="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={selectConversation}
+                                                                       onEdit={handleEditConversation}
+                                                                       onDelete={handleDeleteConversation}
+                                                                       onStop={handleStopGeneration}
+                                                               />
+                                                       </Sidebar.MenuItem>
+                                               {/each}
+                                       </Sidebar.Menu>
+                               </Sidebar.GroupContent>
+                       </Sidebar.Group>
+               {/if}
+
                <Sidebar.Group class="mt-2 h-[calc(100vh-21rem)] space-y-2 p-0 px-3">
                        {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
                                <Sidebar.GroupLabel>
 
                        <Sidebar.GroupContent>
                                <Sidebar.Menu>
-                                       {#each conversationTree as { conversation, depth } (conversation.id)}
+                                       {#each isSearchModeActive ? conversationTree : unpinnedConversations as { conversation, depth } (conversation.id)}
                                                <Sidebar.MenuItem class="mb-1 p-0">
                                                        <SidebarNavigationConversationItem
                                                                conversation={{
                                                                        name: conversation.name,
                                                                        lastModified: conversation.lastModified,
                                                                        currNode: conversation.currNode,
-                                                                       forkedFromConversationId: conversation.forkedFromConversationId
+                                                                       forkedFromConversationId: conversation.forkedFromConversationId,
+                                                                       pinned: conversation.pinned
                                                                }}
                                                                {depth}
                                                                isActive={currentChatId === conversation.id}
                                                </Sidebar.MenuItem>
                                        {/each}
 
-                                       {#if conversationTree.length === 0}
+                                       {#if (isSearchModeActive ? conversationTree : unpinnedConversations).length === 0}
                                                <div class="px-2 py-4 text-center">
                                                        <p class="mb-4 p-4 text-sm text-muted-foreground">
                                                                {searchQuery.length > 0
index e38a937385a350c947ccdcf22c7bab2188ec2564..b1c2b78f65eaa4ccec0b99630ad5a3bfe6f1fbe7 100644 (file)
@@ -6,7 +6,9 @@
                Download,
                Loader2,
                Square,
-               GitBranch
+               GitBranch,
+               Pin,
+               PinOff
        } from '@lucide/svelte';
        import { DropdownMenuActions } from '$lib/components/app';
        import * as Tooltip from '$lib/components/ui/tooltip';
                onStop?.(conversation.id);
        }
 
+       function handleTogglePin() {
+               conversationsStore.toggleConversationPin(conversation.id);
+       }
+
        function handleGlobalEditEvent(event: Event) {
                const customEvent = event as CustomEvent<{ conversationId: string }>;
 
                                triggerTooltip="More actions"
                                bind:open={dropdownOpen}
                                actions={[
+                                       {
+                                               icon: conversation.pinned ? PinOff : Pin,
+                                               label: conversation.pinned ? 'Unpin' : 'Pin',
+                                               onclick: (e: Event) => {
+                                                       e.stopPropagation();
+                                                       handleTogglePin();
+                                               }
+                                       },
                                        {
                                                icon: Pencil,
                                                label: 'Edit',
index 457867d984d66b101ce7b04966c09641d87a0c1f..3338439bbec1fd0a2b51259b019b95ae14991fee 100644 (file)
@@ -344,6 +344,22 @@ export class DatabaseService {
         *
         */
 
+       /**
+        * Toggles the pinned status of a conversation.
+        *
+        * @param id - Conversation ID
+        * @returns The new pinned status
+        */
+       static async toggleConversationPin(id: string): Promise<boolean> {
+               const conversation = await db.conversations.get(id);
+               if (!conversation) {
+                       throw new Error(`Conversation ${id} not found`);
+               }
+               const newPinnedState = !conversation.pinned;
+               await this.updateConversation(id, { pinned: newPinnedState });
+               return newPinnedState;
+       }
+
        /**
         * Updates the conversation's current node (active branch).
         * This determines which conversation path is currently being viewed.
index cab6f59faebdffe06ca52afebf97dbad0906e8c4..bba5191ed57cf6a34b375f614ab539f02676b188 100644 (file)
@@ -506,6 +506,33 @@ class ConversationsStore {
                }
        }
 
+       /**
+        * Toggles the pinned status of a conversation.
+        * @param convId - The conversation ID to toggle
+        * @returns The new pinned status
+        */
+       async toggleConversationPin(convId: string): Promise<boolean> {
+               try {
+                       const newPinnedState = await DatabaseService.toggleConversationPin(convId);
+
+                       const convIndex = this.conversations.findIndex((c) => c.id === convId);
+
+                       if (convIndex !== -1) {
+                               this.conversations[convIndex].pinned = newPinnedState;
+                               this.conversations = [...this.conversations];
+                       }
+
+                       if (this.activeConversation?.id === convId) {
+                               this.activeConversation = { ...this.activeConversation, pinned: newPinnedState };
+                       }
+
+                       return newPinnedState;
+               } catch (error) {
+                       console.error('Failed to toggle conversation pin:', error);
+                       return false;
+               }
+       }
+
        /**
         * Updates conversation title with optional confirmation dialog based on settings
         * @param convId - The conversation ID to update
@@ -1057,6 +1084,14 @@ 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.
  */
+
+// Pinned conversations first, then by lastModified descending
+const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => {
+       if (a.pinned && !b.pinned) return -1;
+       if (!a.pinned && b.pinned) return 1;
+       return b.lastModified - a.lastModified;
+};
+
 export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
        const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
        const forkIds = new SvelteSet<string>();
@@ -1081,7 +1116,7 @@ export function buildConversationTree(convs: DatabaseConversation[]): Conversati
 
                const children = childrenByParent.get(conv.id);
                if (children) {
-                       children.sort((a, b) => b.lastModified - a.lastModified);
+                       children.sort(comparePinnedThenRecent);
 
                        for (const child of children) {
                                walk(child, depth + 1);
@@ -1089,7 +1124,7 @@ export function buildConversationTree(convs: DatabaseConversation[]): Conversati
                }
        }
 
-       const roots = convs.filter((c) => !forkIds.has(c.id));
+       const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent);
        for (const root of roots) {
                walk(root, 0);
        }
index add628476b74e0e022a85b7df6b866443edce018..b01a7b9649c1c0ef39b8402760cf7e798590685b 100644 (file)
@@ -15,6 +15,7 @@ export interface DatabaseConversation {
        thinkingEnabled?: boolean;
        reasoningEffort?: ReasoningEffort;
        forkedFromConversationId?: string;
+       pinned?: boolean;
 }
 
 export interface DatabaseMessageExtraAudioFile {