webui: implement pinned conversations support (#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>
This commit is contained in:
+48
-4
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
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 { Button } from '$lib/components/ui/button';
|
||||||
import { DialogConfirmation } from '$lib/components/app';
|
import { DialogConfirmation } from '$lib/components/app';
|
||||||
import SidebarNavigationActions from './SidebarNavigationActions.svelte';
|
import SidebarNavigationActions from './SidebarNavigationActions.svelte';
|
||||||
@@ -52,6 +52,14 @@
|
|||||||
|
|
||||||
let conversationTree = $derived(buildConversationTree(filteredConversations));
|
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(() => {
|
let selectedConversationHasDescendants = $derived.by(() => {
|
||||||
if (!selectedConversation) return false;
|
if (!selectedConversation) return false;
|
||||||
|
|
||||||
@@ -199,6 +207,41 @@
|
|||||||
/>
|
/>
|
||||||
</Sidebar.Header>
|
</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">
|
<Sidebar.Group class="mt-2 h-[calc(100vh-21rem)] space-y-2 p-0 px-3">
|
||||||
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
|
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
|
||||||
<Sidebar.GroupLabel>
|
<Sidebar.GroupLabel>
|
||||||
@@ -208,7 +251,7 @@
|
|||||||
|
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<Sidebar.Menu>
|
<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">
|
<Sidebar.MenuItem class="mb-1 p-0">
|
||||||
<SidebarNavigationConversationItem
|
<SidebarNavigationConversationItem
|
||||||
conversation={{
|
conversation={{
|
||||||
@@ -216,7 +259,8 @@
|
|||||||
name: conversation.name,
|
name: conversation.name,
|
||||||
lastModified: conversation.lastModified,
|
lastModified: conversation.lastModified,
|
||||||
currNode: conversation.currNode,
|
currNode: conversation.currNode,
|
||||||
forkedFromConversationId: conversation.forkedFromConversationId
|
forkedFromConversationId: conversation.forkedFromConversationId,
|
||||||
|
pinned: conversation.pinned
|
||||||
}}
|
}}
|
||||||
{depth}
|
{depth}
|
||||||
isActive={currentChatId === conversation.id}
|
isActive={currentChatId === conversation.id}
|
||||||
@@ -228,7 +272,7 @@
|
|||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if conversationTree.length === 0}
|
{#if (isSearchModeActive ? conversationTree : unpinnedConversations).length === 0}
|
||||||
<div class="px-2 py-4 text-center">
|
<div class="px-2 py-4 text-center">
|
||||||
<p class="mb-4 p-4 text-sm text-muted-foreground">
|
<p class="mb-4 p-4 text-sm text-muted-foreground">
|
||||||
{searchQuery.length > 0
|
{searchQuery.length > 0
|
||||||
|
|||||||
+15
-1
@@ -6,7 +6,9 @@
|
|||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Square,
|
Square,
|
||||||
GitBranch
|
GitBranch,
|
||||||
|
Pin,
|
||||||
|
PinOff
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { DropdownMenuActions } from '$lib/components/app';
|
import { DropdownMenuActions } from '$lib/components/app';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -57,6 +59,10 @@
|
|||||||
onStop?.(conversation.id);
|
onStop?.(conversation.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTogglePin() {
|
||||||
|
conversationsStore.toggleConversationPin(conversation.id);
|
||||||
|
}
|
||||||
|
|
||||||
function handleGlobalEditEvent(event: Event) {
|
function handleGlobalEditEvent(event: Event) {
|
||||||
const customEvent = event as CustomEvent<{ conversationId: string }>;
|
const customEvent = event as CustomEvent<{ conversationId: string }>;
|
||||||
|
|
||||||
@@ -170,6 +176,14 @@
|
|||||||
triggerTooltip="More actions"
|
triggerTooltip="More actions"
|
||||||
bind:open={dropdownOpen}
|
bind:open={dropdownOpen}
|
||||||
actions={[
|
actions={[
|
||||||
|
{
|
||||||
|
icon: conversation.pinned ? PinOff : Pin,
|
||||||
|
label: conversation.pinned ? 'Unpin' : 'Pin',
|
||||||
|
onclick: (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTogglePin();
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Pencil,
|
icon: Pencil,
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
|||||||
@@ -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).
|
* Updates the conversation's current node (active branch).
|
||||||
* This determines which conversation path is currently being viewed.
|
* This determines which conversation path is currently being viewed.
|
||||||
|
|||||||
@@ -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
|
* Updates conversation title with optional confirmation dialog based on settings
|
||||||
* @param convId - The conversation ID to update
|
* @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.
|
* Builds a flat tree of conversations with depth levels for nested forks.
|
||||||
* Accepts a pre-filtered list so search filtering stays in the component.
|
* 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[] {
|
export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
|
||||||
const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
|
const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
|
||||||
const forkIds = new SvelteSet<string>();
|
const forkIds = new SvelteSet<string>();
|
||||||
@@ -1081,7 +1116,7 @@ export function buildConversationTree(convs: DatabaseConversation[]): Conversati
|
|||||||
|
|
||||||
const children = childrenByParent.get(conv.id);
|
const children = childrenByParent.get(conv.id);
|
||||||
if (children) {
|
if (children) {
|
||||||
children.sort((a, b) => b.lastModified - a.lastModified);
|
children.sort(comparePinnedThenRecent);
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
walk(child, depth + 1);
|
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) {
|
for (const root of roots) {
|
||||||
walk(root, 0);
|
walk(root, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -15,6 +15,7 @@ export interface DatabaseConversation {
|
|||||||
thinkingEnabled?: boolean;
|
thinkingEnabled?: boolean;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
forkedFromConversationId?: string;
|
forkedFromConversationId?: string;
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseMessageExtraAudioFile {
|
export interface DatabaseMessageExtraAudioFile {
|
||||||
|
|||||||
Reference in New Issue
Block a user