<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
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',
*
*/
+ /**
+ * 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.
}
}
+ /**
+ * 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
* 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>();
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);
}
}
- 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);
}
thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort;
forkedFromConversationId?: string;
+ pinned?: boolean;
}
export interface DatabaseMessageExtraAudioFile {