refactor: Create shared ActiveConversationStore to avoid circular dependency between ChatStore and ConversationsStore

This commit is contained in:
Aleksander Grygier 2026-01-27 14:27:13 +01:00
parent 9cce846f32
commit 7ba1b458d5
4 changed files with 112 additions and 23 deletions

View File

@ -33,6 +33,7 @@ import {
MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
} from '$lib/constants/cache';
import { isActiveConversation } from '$lib/stores/shared';
interface ConversationStateEntry {
lastAccessed: number;
@ -56,6 +57,14 @@ class ChatStore {
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null);
/**
* Callback for updating message content in conversationsStore.
* Registered by conversationsStore to avoid circular dependency.
*/
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
// Draft preservation for navigation (e.g., when adding system prompt from welcome page)
private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
@ -84,30 +93,24 @@ class ChatStore {
private setChatLoading(convId: string, loading: boolean): void {
this.touchConversationState(convId);
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
if (loading) {
this.chatLoadingStates.set(convId, true);
if (conversationsStore.activeConversation?.id === convId) this.isLoading = true;
} else {
this.chatLoadingStates.delete(convId);
if (conversationsStore.activeConversation?.id === convId) this.isLoading = false;
}
});
if (loading) {
this.chatLoadingStates.set(convId, true);
if (isActiveConversation(convId)) this.isLoading = true;
} else {
this.chatLoadingStates.delete(convId);
if (isActiveConversation(convId)) this.isLoading = false;
}
}
private setChatStreaming(convId: string, response: string, messageId: string): void {
this.touchConversationState(convId);
this.chatStreamingStates.set(convId, { response, messageId });
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
});
if (isActiveConversation(convId)) this.currentResponse = response;
}
private clearChatStreaming(convId: string): void {
this.chatStreamingStates.delete(convId);
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
if (conversationsStore.activeConversation?.id === convId) this.currentResponse = '';
});
if (isActiveConversation(convId)) this.currentResponse = '';
}
private getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
@ -121,16 +124,21 @@ class ChatStore {
// If there's an active stream for this conversation, update the message content
// This ensures streaming content is visible when switching back to a conversation
if (streamingState?.response && streamingState?.messageId) {
import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => {
const idx = conversationsStore.findMessageIndex(streamingState.messageId);
if (idx !== -1) {
conversationsStore.updateMessageAtIndex(idx, { content: streamingState.response });
}
});
if (streamingState?.response && streamingState?.messageId && this.messageUpdateCallback) {
this.messageUpdateCallback(streamingState.messageId, { content: streamingState.response });
}
}
/**
* Register a callback for updating message content.
* Called by conversationsStore during initialization to avoid circular dependency.
*/
registerMessageUpdateCallback(
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
): void {
this.messageUpdateCallback = callback;
}
clearUIState(): void {
this.isLoading = false;
this.currentResponse = '';

View File

@ -21,6 +21,8 @@
import { browser } from '$app/environment';
import type { McpServerOverride } from '$lib/types/database';
import { setActiveConversationId } from '$lib/stores/shared';
import { chatStore } from '$lib/stores/chat.svelte';
class ConversationsStore {
/** List of all conversations */
@ -65,11 +67,23 @@ class ConversationsStore {
const { conversationsClient } = await import('$lib/clients/conversations.client');
this._client = conversationsClient;
// Register message update callback with chatStore to avoid circular dependency
chatStore.registerMessageUpdateCallback((messageId, updates) => {
const idx = this.findMessageIndex(messageId);
if (idx !== -1) {
this.updateMessageAtIndex(idx, updates);
}
});
conversationsClient.setStoreCallbacks({
getConversations: () => this.conversations,
setConversations: (conversations) => (this.conversations = conversations),
getActiveConversation: () => this.activeConversation,
setActiveConversation: (conversation) => (this.activeConversation = conversation),
setActiveConversation: (conversation) => {
this.activeConversation = conversation;
// Update shared state for chatStore to use without circular dependency
setActiveConversationId(conversation?.id ?? null);
},
getActiveMessages: () => this.activeMessages,
setActiveMessages: (messages) => (this.activeMessages = messages),
updateActiveMessages: (updater) => (this.activeMessages = updater(this.activeMessages)),

View File

@ -0,0 +1,49 @@
/**
* Shared Active Conversation State
*
* This module provides a dependency-free shared state for tracking the active conversation.
* It eliminates circular dependencies between chatStore and conversationsStore.
*
* **Why this exists:**
* - chatStore needs to know the active conversation ID to sync global loading/streaming state
* - conversationsStore manages the active conversation
* - Direct imports between stores would create circular dependencies
*
* **Usage:**
* - conversationsStore: calls setId() when switching conversations
* - chatStore: calls isActive() to check if state should sync to global
*/
class ActiveConversationStore {
private _id = $state<string | null>(null);
/**
* Get the currently active conversation ID.
* Returns null if no conversation is active.
*/
get id(): string | null {
return this._id;
}
/**
* Set the active conversation ID.
* Should only be called by conversationsStore when switching conversations.
*/
setId(id: string | null): void {
this._id = id;
}
/**
* Check if the given conversation ID is the currently active one.
*/
isActive(convId: string): boolean {
return this._id === convId;
}
}
export const activeConversationStore = new ActiveConversationStore();
// Convenience exports for backward compatibility
export const getActiveConversationId = () => activeConversationStore.id;
export const setActiveConversationId = (id: string | null) => activeConversationStore.setId(id);
export const isActiveConversation = (convId: string) => activeConversationStore.isActive(convId);

View File

@ -0,0 +1,18 @@
/**
* Shared State Modules
*
* This directory contains dependency-free state modules that can be safely
* imported by any store without creating circular dependencies.
*
* **Rules for modules in this folder:**
* - NO imports from other stores
* - NO imports from clients or services
* - Only pure reactive state with no business logic
*/
export {
activeConversationStore,
getActiveConversationId,
setActiveConversationId,
isActiveConversation
} from './active-conversation.svelte';