serene-pub/src/lib/client/components/sidebars/ChatsSidebar.svelte
Jody Doolittle db1c50ecd8 Extract ListItem components, enhance tag search, fix double-brace syntax, and improve chat UX
Component Architecture
NEW: Extract LorebookListItem, PersonaListItem components for consistency
UPDATE: Refactor all sidebars to use dedicated ListItem components
ENHANCE: Add tag-based search across Characters, Personas, and Chats sidebars
Template Syntax Standardization
FIX: Standardize on {{char:#}} double-brace syntax throughout codebase
UPDATE: TipTap extensions, lorebook bindings, and prompt templates
MIGRATE: Legacy {char:#} syntax support maintained for backwards compatibility
Chat Experience Improvements
NEW: Next character indicator in group chats with continue/choose options
ADD: getChatResponseOrder socket for intelligent character turn management
ENHANCE: Auto-scroll behavior with retry logic and position restoration
FIX: Proper response order after message deletion and creation
Tag System Integration
COMPLETE: Full tag support for Lorebooks with CRUD operations
ADD: processLorebookTags helper function for tag management
ENHANCE: Tag search functionality across all entity types
UPDATE: Socket interfaces to include tag arrays
Database & Infrastructure
IMPROVE: Database lock management with proper cleanup
STANDARDIZE: Data directory handling with environment variable support
OPTIMIZE: Query efficiency with proper tag relationships
FIX: PGlite configuration and migration handling
Bug Fixes & Polish
FIX: Italic markdown parsing with trailing spaces
REMOVE: Debug console.log statements throughout codebase
ENHANCE: Loading states and error handling in forms
IMPROVE: Socket event naming consistency and response handling
2025-08-09 00:25:09 -07:00

346 lines
9.1 KiB
Svelte

<script lang="ts">
import { getContext, onMount } from "svelte"
import * as skio from "sveltekit-io"
import EditChatForm from "../chatForms/EditChatForm.svelte"
import * as Icons from "@lucide/svelte"
import { Modal } from "@skeletonlabs/skeleton-svelte"
import { goto } from "$app/navigation"
import { toaster } from "$lib/client/utils/toaster"
import { page } from "$app/state"
import ChatListItem from "../listItems/ChatListItem.svelte"
import ChatsUnsavedChangesModal from "../modals/ChatsUnsavedChangesModal.svelte"
interface Props {
onclose?: () => Promise<boolean> | undefined
}
let { onclose = $bindable() }: Props = $props()
let chats: Sockets.ChatsList.Response["chatsList"] = $state([])
let search = $state("")
let showEditChatForm = $state(false)
let panelsCtx: PanelsCtx = $state(getContext("panelsCtx"))
let searchByCharacterId: number | null = $state(null)
let searchByPersonaId: number | null = $state(null)
let searchCharacter: SelectCharacter | null = $state(null)
let searchPersona: SelectPersona | null = $state(null)
let editChatId: number | null = $state(null)
let chatFormHasChanges = $state(false)
let showUnsavedChangesModal = $state(false)
let confirmCloseSidebarResolve: ((v: boolean) => void) | null = null
const socket = skio.get()
// Filtered chats derived from search
let filteredChats: Sockets.ChatsList.Response["chatsList"] = $state([])
socket.on("chatsList", (msg: Sockets.ChatsList.Response) => {
chats = msg.chatsList || []
})
async function handleOnClose() {
if (chatFormHasChanges) {
showUnsavedChangesModal = true
return new Promise<boolean>((resolve) => {
confirmCloseSidebarResolve = resolve
})
} else {
// Remove "chats-by-characterId" and "chats-by-personaId" from search params
const url = new URL(window.location.href)
url.searchParams.delete("chats-by-characterId")
url.searchParams.delete("chats-by-personaId")
goto(url.toString(), { replaceState: true })
return true
}
}
function handleCreateClick(
event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }
) {
// Clear tutorial flag when user interacts with the highlighted button
if (panelsCtx.digest.tutorial) {
panelsCtx.digest.tutorial = false
}
showEditChatForm = true
}
function handleEditClick(chatId: number) {
showEditChatForm = true
editChatId = chatId
}
function handleChatClick(chat: any) {
goto(`/chats/${chat.id}`)
// Check if mobile menu/sidebar is open and close it
if (panelsCtx.isMobileMenuOpen) {
panelsCtx.isMobileMenuOpen = false
}
if (panelsCtx.mobilePanel) {
panelsCtx.mobilePanel = null
}
}
let showDeleteModal = $state(false)
let chatToDelete: number | null = $state(null)
function handleDeleteClick(chatId: number) {
chatToDelete = chatId
showDeleteModal = true
}
function cancelDelete() {
showDeleteModal = false
chatToDelete = null
}
function confirmDelete() {
if (chatToDelete != null) {
if (page.params.id === chatToDelete.toString()) {
// If the current chat is being deleted, navigate away
goto("/")
}
socket.emit("deleteChat", { id: chatToDelete })
console.log("Deleting chat with ID:", chatToDelete)
showDeleteModal = false
chatToDelete = null
}
}
socket.on("deleteChat", (msg) => {
chats = chats.filter((c) => c.id !== msg.id)
toaster.success({ title: "Chat deleted" })
})
function handleCloseModalDiscard() {
showUnsavedChangesModal = false
// Clear search params when discarding changes and closing
const url = new URL(window.location.href)
url.searchParams.delete("chats-by-characterId")
url.searchParams.delete("chats-by-personaId")
goto(url.toString(), { replaceState: true })
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(true)
}
function handleCloseModalCancel() {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
function handleUnsavedChangesOnOpenChange(e: { open: boolean }) {
if (!e.open) {
showUnsavedChangesModal = false
if (confirmCloseSidebarResolve) confirmCloseSidebarResolve(false)
}
}
$effect(() => {
const lower = search.toLowerCase()
let filtered = [...chats]
// If searching by character ID, filter chats that include that character
if (searchByCharacterId) {
filtered = filtered.filter((chat) =>
chat.chatCharacters?.some(
(cc) => cc.character?.id === searchByCharacterId
)
)
}
// If searching by persona ID, filter chats that include that persona
if (searchByPersonaId) {
filtered = filtered.filter((chat) =>
chat.chatPersonas?.some(
(cp) => cp.persona?.id === searchByPersonaId
)
)
}
filteredChats = filtered.filter((chat) => {
const chatName = chat.name?.toLowerCase() || ""
const personaNames = (chat.chatPersonas || [])
.map((cp) => cp.persona?.name?.toLowerCase() || "")
.join(" ")
const characterNames = (chat.chatCharacters || [])
.map((cc) => cc.character?.name?.toLowerCase() || "")
.join(" ")
const tagNames = (chat.chatTags || [])
.map((ct: any) => ct.tag?.name?.toLowerCase() || "")
.join(" ")
return (
chatName.includes(lower) ||
personaNames.includes(lower) ||
characterNames.includes(lower) ||
tagNames.includes(lower)
)
})
})
$effect(() => {
if (panelsCtx.digest.chatId) {
showEditChatForm = true
editChatId = panelsCtx.digest.chatId
delete panelsCtx.digest.chatId
delete panelsCtx.digest.chatCharacterId
delete panelsCtx.digest.chatPersonaId
}
})
$effect(() => {
if (panelsCtx.digest.chatCharacterId) {
searchByCharacterId = panelsCtx.digest.chatCharacterId
}
if (panelsCtx.digest.chatPersonaId) {
searchByPersonaId = panelsCtx.digest.chatPersonaId
}
delete panelsCtx.digest.chatCharacterId
delete panelsCtx.digest.chatPersonaId
})
$effect(() => {
if (searchByCharacterId) {
socket.once("character", (msg: Sockets.Character.Response) => {
searchCharacter = msg.character
})
const charIdReq: Sockets.Character.Call = {
id: searchByCharacterId
}
socket.emit("character", charIdReq)
}
})
$effect(() => {
if (searchByPersonaId) {
socket.once("persona", (msg: Sockets.Persona.Response) => {
searchPersona = msg.persona
})
const personaIdReq: Sockets.Persona.Call = {
id: searchByPersonaId
}
socket.emit("persona", personaIdReq)
}
})
$effect(() => {
if (editChatId && !showEditChatForm) {
editChatId = null
}
})
onMount(() => {
socket.emit("chatsList", {})
onclose = handleOnClose
})
</script>
<div class="text-foreground flex h-full flex-col p-4">
{#if showEditChatForm}
<EditChatForm
bind:showEditChatForm
bind:editChatId
bind:hasChanges={chatFormHasChanges}
/>
{:else}
<div class="mb-2 flex gap-2">
<button
class="btn btn-sm preset-filled-primary-500 {panelsCtx.digest
.tutorial
? 'ring-primary-500/50 animate-pulse ring-4'
: ''}"
onclick={handleCreateClick}
title="Create New Chat"
>
<Icons.Plus size={16} />
</button>
</div>
<div class="mb-4 flex items-center gap-2">
<input
class="input w-full"
type="text"
placeholder="Search chats, personas, characters, tags..."
bind:value={search}
/>
</div>
{#if searchCharacter}
<button
class="btn btn-sm preset-filled-secondary-500 mb-2"
onclick={() => {
searchByCharacterId = null
searchCharacter = null
page.url.searchParams.delete("chats-by-characterId")
}}
>
<Icons.X size={16} />
{searchCharacter.nickname || searchCharacter.name}
</button>
{/if}
{#if searchPersona}
<button
class="btn btn-sm preset-filled-secondary-500 mb-2"
onclick={() => {
searchByPersonaId = null
searchPersona = null
page.url.searchParams.delete("chats-by-personaId")
}}
>
<Icons.X size={16} />
{searchPersona.name}
</button>
{/if}
<div class="flex-1 overflow-y-auto">
{#if filteredChats.length === 0}
<div class="text-muted">No chats found.</div>
{:else}
<ul class="flex flex-col gap-2">
{#each filteredChats as chat}
<ChatListItem
{chat}
onclick={handleChatClick}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<Modal
open={showDeleteModal}
onOpenChange={(e) => (showDeleteModal = e.open)}
contentBase="card bg-surface-100-900 p-4 space-y-4 shadow-xl max-w-dvw-sm border border-surface-300-700"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<div class="p-6">
<h2 class="mb-2 text-lg font-bold">Delete Chat?</h2>
<p class="mb-4">
Are you sure you want to delete this chat and all of it's
messages? This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button
class="btn preset-filled-surface-500"
onclick={cancelDelete}
>
Cancel
</button>
<button
class="btn preset-filled-error-500"
onclick={confirmDelete}
>
Delete
</button>
</div>
</div>
{/snippet}
</Modal>
{#if showUnsavedChangesModal}
<ChatsUnsavedChangesModal
open={showUnsavedChangesModal}
onOpenChange={handleUnsavedChangesOnOpenChange}
onConfirm={handleCloseModalDiscard}
onCancel={handleCloseModalCancel}
/>
{/if}