feat: improve chat UX and add optional auth for public feedback

- Add debounced search (200ms) in chat sidebar for better performance
- Add toast notifications for conversation actions (archive, restore, delete, pin)
- Add race condition protection when loading conversations
- Add OptionalAuthGuard for public feedback endpoint (unauthenticated access)
- Add backHref prop to PageHeader component for back navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 23:10:03 +01:00
parent 0893ed7daa
commit c85cd4556c
7 changed files with 192 additions and 53 deletions

View file

@ -26,18 +26,36 @@
const MIN_WIDTH = 260;
const MAX_WIDTH = 450;
// Search state
// Search state with debouncing
let searchQuery = $state('');
let debouncedSearchQuery = $state('');
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Debounce search input (200ms delay)
$effect(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
debouncedSearchQuery = searchQuery;
}, 200);
return () => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
};
});
// Get conversations from store
let conversations = $derived(conversationsStore.conversations);
let isLoading = $derived(conversationsStore.isLoading);
// Filtered conversations based on search
// Filtered conversations based on debounced search
let filteredConversations = $derived(
searchQuery.trim()
debouncedSearchQuery.trim()
? conversations.filter((conv) =>
conv.title?.toLowerCase().includes(searchQuery.toLowerCase())
conv.title?.toLowerCase().includes(debouncedSearchQuery.toLowerCase())
)
: conversations
);

View file

@ -12,6 +12,17 @@ let archivedConversations = $state<Conversation[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
/**
* Sort conversations: pinned first, then by updatedAt descending
*/
function sortConversations(list: Conversation[]): Conversation[] {
return [...list].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
export const conversationsStore = {
// Getters
get conversations() {
@ -102,6 +113,9 @@ export const conversationsStore = {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = [{ ...conversation, isArchived: true }, ...archivedConversations];
}
toastStore.success('Konversation archiviert');
} else {
toastStore.error('Konversation konnte nicht archiviert werden');
}
return success;
@ -117,8 +131,11 @@ export const conversationsStore = {
const conversation = archivedConversations.find((c) => c.id === conversationId);
if (conversation) {
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
conversations = [{ ...conversation, isArchived: false }, ...conversations];
conversations = sortConversations([{ ...conversation, isArchived: false }, ...conversations]);
}
toastStore.success('Konversation wiederhergestellt');
} else {
toastStore.error('Konversation konnte nicht wiederhergestellt werden');
}
return success;
@ -133,6 +150,9 @@ export const conversationsStore = {
if (success) {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
toastStore.success('Konversation gelöscht');
} else {
toastStore.error('Konversation konnte nicht gelöscht werden');
}
return success;
@ -145,15 +165,11 @@ export const conversationsStore = {
const success = await conversationService.pinConversation(conversationId);
if (success) {
conversations = conversations.map((c) =>
c.id === conversationId ? { ...c, isPinned: true } : c
conversations = sortConversations(
conversations.map((c) => (c.id === conversationId ? { ...c, isPinned: true } : c))
);
// Re-sort: pinned first, then by updatedAt
conversations = [...conversations].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
} else {
toastStore.error('Konversation konnte nicht angepinnt werden');
}
return success;
@ -166,15 +182,11 @@ export const conversationsStore = {
const success = await conversationService.unpinConversation(conversationId);
if (success) {
conversations = conversations.map((c) =>
c.id === conversationId ? { ...c, isPinned: false } : c
conversations = sortConversations(
conversations.map((c) => (c.id === conversationId ? { ...c, isPinned: false } : c))
);
// Re-sort: pinned first, then by updatedAt
conversations = [...conversations].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
} else {
toastStore.error('Konversation konnte nicht losgelöst werden');
}
return success;

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { conversationService } from '$lib/services/conversation';
import { chatService } from '$lib/services/chat';
import { documentService } from '$lib/services/document';
import { authStore } from '$lib/stores/auth.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
import ChatLayout from '$lib/components/chat/ChatLayout.svelte';
@ -33,44 +33,77 @@
let showVersionsModal = $state(false);
let showDocumentPanel = $state(true);
// Track current request to prevent race conditions
let currentLoadId = $state(0);
const conversationId = $derived($page.params.id ?? '');
const isDocumentMode = $derived(conversation?.documentMode ?? false);
onMount(async () => {
await loadData();
// React to conversationId changes with race condition protection
$effect(() => {
if (conversationId) {
loadData(conversationId);
}
});
async function loadData() {
async function loadData(targetConversationId: string) {
// Increment load ID to track this request
const loadId = ++currentLoadId;
isLoading = true;
error = null;
try {
// Load models
models = await chatService.getModels();
const loadedModels = await chatService.getModels();
// Check if this request is still current
if (loadId !== currentLoadId) return;
models = loadedModels;
// Load conversation
conversation = await conversationService.getConversation(conversationId);
const loadedConversation = await conversationService.getConversation(targetConversationId);
if (!conversation) {
// Check if this request is still current
if (loadId !== currentLoadId) return;
if (!loadedConversation) {
error = 'Konversation nicht gefunden';
return;
}
// Set model from conversation
selectedModelId = conversation.modelId;
conversation = loadedConversation;
selectedModelId = loadedConversation.modelId;
// Load messages
messages = await conversationService.getMessages(conversationId);
const loadedMessages = await conversationService.getMessages(targetConversationId);
// Check if this request is still current
if (loadId !== currentLoadId) return;
messages = loadedMessages;
// Load document if in document mode
if (conversation.documentMode) {
document = await documentService.getLatestDocument(conversationId);
documentContent = document?.content ?? '';
if (loadedConversation.documentMode) {
const loadedDocument = await documentService.getLatestDocument(targetConversationId);
// Check if this request is still current
if (loadId !== currentLoadId) return;
document = loadedDocument;
documentContent = loadedDocument?.content ?? '';
} else {
document = null;
documentContent = '';
}
} catch (e) {
// Only show error if this request is still current
if (loadId !== currentLoadId) return;
error = e instanceof Error ? e.message : 'Fehler beim Laden';
toastStore.error(error);
} finally {
isLoading = false;
// Only update loading state if this request is still current
if (loadId === currentLoadId) {
isLoading = false;
}
}
}