Skip to content

Commit 76da245

Browse files
webui: implement pinned conversations support (ggml-org#21387)
* 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 <admin@serveurperso.com> --------- Co-authored-by: Pascal <admin@serveurperso.com>
1 parent d73cd07 commit 76da245

5 files changed

Lines changed: 117 additions & 7 deletions

File tree

tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { goto } from '$app/navigation';
33
import { page } from '$app/state';
4-
import { Trash2, Pencil, X } from '@lucide/svelte';
4+
import { Trash2, Pencil, Pin, X } from '@lucide/svelte';
55
import { Button } from '$lib/components/ui/button';
66
import { DialogConfirmation } from '$lib/components/app';
77
import SidebarNavigationActions from './SidebarNavigationActions.svelte';
@@ -52,6 +52,14 @@
5252
5353
let conversationTree = $derived(buildConversationTree(filteredConversations));
5454
55+
let pinnedConversations = $derived.by(() => {
56+
return conversationTree.filter(({ conversation }) => conversation.pinned);
57+
});
58+
59+
let unpinnedConversations = $derived.by(() => {
60+
return conversationTree.filter(({ conversation }) => !conversation.pinned);
61+
});
62+
5563
let selectedConversationHasDescendants = $derived.by(() => {
5664
if (!selectedConversation) return false;
5765
@@ -199,6 +207,41 @@
199207
/>
200208
</Sidebar.Header>
201209

210+
{#if !isSearchModeActive && pinnedConversations.length > 0}
211+
<Sidebar.Group class="p-0 px-4">
212+
<Sidebar.GroupLabel>
213+
<div class="flex items-center gap-1">
214+
<Pin class="h-3.5 w-3.5" />
215+
<span>Pinned</span>
216+
</div>
217+
</Sidebar.GroupLabel>
218+
<Sidebar.GroupContent>
219+
<Sidebar.Menu>
220+
{#each pinnedConversations as { conversation, depth } (conversation.id)}
221+
<Sidebar.MenuItem class="mb-1 p-0">
222+
<SidebarNavigationConversationItem
223+
conversation={{
224+
id: conversation.id,
225+
name: conversation.name,
226+
lastModified: conversation.lastModified,
227+
currNode: conversation.currNode,
228+
forkedFromConversationId: conversation.forkedFromConversationId,
229+
pinned: conversation.pinned
230+
}}
231+
{depth}
232+
isActive={currentChatId === conversation.id}
233+
onSelect={selectConversation}
234+
onEdit={handleEditConversation}
235+
onDelete={handleDeleteConversation}
236+
onStop={handleStopGeneration}
237+
/>
238+
</Sidebar.MenuItem>
239+
{/each}
240+
</Sidebar.Menu>
241+
</Sidebar.GroupContent>
242+
</Sidebar.Group>
243+
{/if}
244+
202245
<Sidebar.Group class="mt-2 h-[calc(100vh-21rem)] space-y-2 p-0 px-3">
203246
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
204247
<Sidebar.GroupLabel>
@@ -208,15 +251,16 @@
208251

209252
<Sidebar.GroupContent>
210253
<Sidebar.Menu>
211-
{#each conversationTree as { conversation, depth } (conversation.id)}
254+
{#each isSearchModeActive ? conversationTree : unpinnedConversations as { conversation, depth } (conversation.id)}
212255
<Sidebar.MenuItem class="mb-1 p-0">
213256
<SidebarNavigationConversationItem
214257
conversation={{
215258
id: conversation.id,
216259
name: conversation.name,
217260
lastModified: conversation.lastModified,
218261
currNode: conversation.currNode,
219-
forkedFromConversationId: conversation.forkedFromConversationId
262+
forkedFromConversationId: conversation.forkedFromConversationId,
263+
pinned: conversation.pinned
220264
}}
221265
{depth}
222266
isActive={currentChatId === conversation.id}
@@ -228,7 +272,7 @@
228272
</Sidebar.MenuItem>
229273
{/each}
230274

231-
{#if conversationTree.length === 0}
275+
{#if (isSearchModeActive ? conversationTree : unpinnedConversations).length === 0}
232276
<div class="px-2 py-4 text-center">
233277
<p class="mb-4 p-4 text-sm text-muted-foreground">
234278
{searchQuery.length > 0

tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
Download,
77
Loader2,
88
Square,
9-
GitBranch
9+
GitBranch,
10+
Pin,
11+
PinOff
1012
} from '@lucide/svelte';
1113
import { DropdownMenuActions } from '$lib/components/app';
1214
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -57,6 +59,10 @@
5759
onStop?.(conversation.id);
5860
}
5961
62+
function handleTogglePin() {
63+
conversationsStore.toggleConversationPin(conversation.id);
64+
}
65+
6066
function handleGlobalEditEvent(event: Event) {
6167
const customEvent = event as CustomEvent<{ conversationId: string }>;
6268
@@ -170,6 +176,14 @@
170176
triggerTooltip="More actions"
171177
bind:open={dropdownOpen}
172178
actions={[
179+
{
180+
icon: conversation.pinned ? PinOff : Pin,
181+
label: conversation.pinned ? 'Unpin' : 'Pin',
182+
onclick: (e: Event) => {
183+
e.stopPropagation();
184+
handleTogglePin();
185+
}
186+
},
173187
{
174188
icon: Pencil,
175189
label: 'Edit',

tools/ui/src/lib/services/database.service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,22 @@ export class DatabaseService {
344344
*
345345
*/
346346

347+
/**
348+
* Toggles the pinned status of a conversation.
349+
*
350+
* @param id - Conversation ID
351+
* @returns The new pinned status
352+
*/
353+
static async toggleConversationPin(id: string): Promise<boolean> {
354+
const conversation = await db.conversations.get(id);
355+
if (!conversation) {
356+
throw new Error(`Conversation ${id} not found`);
357+
}
358+
const newPinnedState = !conversation.pinned;
359+
await this.updateConversation(id, { pinned: newPinnedState });
360+
return newPinnedState;
361+
}
362+
347363
/**
348364
* Updates the conversation's current node (active branch).
349365
* This determines which conversation path is currently being viewed.

tools/ui/src/lib/stores/conversations.svelte.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,33 @@ class ConversationsStore {
506506
}
507507
}
508508

509+
/**
510+
* Toggles the pinned status of a conversation.
511+
* @param convId - The conversation ID to toggle
512+
* @returns The new pinned status
513+
*/
514+
async toggleConversationPin(convId: string): Promise<boolean> {
515+
try {
516+
const newPinnedState = await DatabaseService.toggleConversationPin(convId);
517+
518+
const convIndex = this.conversations.findIndex((c) => c.id === convId);
519+
520+
if (convIndex !== -1) {
521+
this.conversations[convIndex].pinned = newPinnedState;
522+
this.conversations = [...this.conversations];
523+
}
524+
525+
if (this.activeConversation?.id === convId) {
526+
this.activeConversation = { ...this.activeConversation, pinned: newPinnedState };
527+
}
528+
529+
return newPinnedState;
530+
} catch (error) {
531+
console.error('Failed to toggle conversation pin:', error);
532+
return false;
533+
}
534+
}
535+
509536
/**
510537
* Updates conversation title with optional confirmation dialog based on settings
511538
* @param convId - The conversation ID to update
@@ -1057,6 +1084,14 @@ export const isConversationsInitialized = () => conversationsStore.isInitialized
10571084
* Builds a flat tree of conversations with depth levels for nested forks.
10581085
* Accepts a pre-filtered list so search filtering stays in the component.
10591086
*/
1087+
1088+
// Pinned conversations first, then by lastModified descending
1089+
const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => {
1090+
if (a.pinned && !b.pinned) return -1;
1091+
if (!a.pinned && b.pinned) return 1;
1092+
return b.lastModified - a.lastModified;
1093+
};
1094+
10601095
export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
10611096
const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
10621097
const forkIds = new SvelteSet<string>();
@@ -1081,15 +1116,15 @@ export function buildConversationTree(convs: DatabaseConversation[]): Conversati
10811116

10821117
const children = childrenByParent.get(conv.id);
10831118
if (children) {
1084-
children.sort((a, b) => b.lastModified - a.lastModified);
1119+
children.sort(comparePinnedThenRecent);
10851120

10861121
for (const child of children) {
10871122
walk(child, depth + 1);
10881123
}
10891124
}
10901125
}
10911126

1092-
const roots = convs.filter((c) => !forkIds.has(c.id));
1127+
const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent);
10931128
for (const root of roots) {
10941129
walk(root, 0);
10951130
}

tools/ui/src/lib/types/database.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface DatabaseConversation {
1515
thinkingEnabled?: boolean;
1616
reasoningEffort?: ReasoningEffort;
1717
forkedFromConversationId?: string;
18+
pinned?: boolean;
1819
}
1920

2021
export interface DatabaseMessageExtraAudioFile {

0 commit comments

Comments
 (0)