* ui: Extract server stream lifecycle from chatStore into ChatStreamManager
Discovery, attach/replay, resume retry and the remote-running snapshot
formed a cohesive cluster inside chatStore. It now lives in
chat-streams.svelte.ts as ChatStreamManager, owned by chatStore, which
keeps the public entry points as delegates so components are
unchanged. chatStore: 2877 -> 2418 lines.
* ui: Extract user interaction gates from agenticStore into AgenticGates
Tool permission requests, turn-limit continue prompts and queued
steering messages are the state the loop waits on between turns. They
had no coupling to session state, so they now live in
agentic-gates.svelte.ts; agenticStore keeps delegates so components
are unchanged. agenticStore: 1196 -> 1073 lines.
* ui: Compose MCP resources under mcpStore.resources
Resource state was a second import scope next to mcpStore. Consumers
now go through mcpStore.resources, so the MCP surface is one store;
mcp-resources.svelte.ts stays a separate file owned by mcpStore.
* ui: Reorganize stores into domain namespaces
* fix: Update stale doc comments
* ui: Consolidate conv running-state into a chat activity ledger
Running-state was split across chatStore.chatLoadingStates (local
pipes), ChatStreamManager.remoteRunningConvs (backend sessions) and
attachingConvs (attach lifecycle), unioned by hand in
getAllLoadingChats and cross-cleaned by setChatLoading calling
streams.clearRemoteRunning - the 'spinner ghosts until tab toggle'
workaround.
chatActivityStore now owns both sets with one transition per event:
markLocal / localEnded (local pipe end also drops the stale remote
hint, no cross-owner call) / applyRemoteSnapshot (diffed). The
sidebar reads chatStore.activity.loadingConvs through the unchanged
getAllLoadingChats entry point.
Consequences:
- isStreamingActive and its five manual writers are gone; isStreaming()
now reports whether the active conversation has a live streaming
pipe, which is what all four consumers (assistant row, stop action,
context gauge, chat screen) actually check
- isLoading/isReasoning become derived from the per-conv maps plus
the active conversation, dropping the manual resync in
syncLoadingStateForChat and clearUIState
- attachingConvs and the last-attach coordination disappear from
ChatStreamManager
- getAllStreamingChats (no consumers) is removed
* ui: Give store collaborators narrow host interfaces
Collaborators took 'host: typeof <store>', i.e. the store's entire
public surface, which is how chatStore's streamChatCompletion,
createAssistantMessage, getApiOptions and setStreamingActive got
widened to public. Replace with per-collaborator interfaces carrying
only the members each one drives:
- ChatStreamHost (chat/streams) - activity, processing, streaming
states, abort controller, loading/streaming setters
- ChatFlowsHost (chat/flows) - streaming core, message creation,
per-conv state setters
- McpHealthHost (mcp/health) - connection registry + reconnection
- ModelPropsHost / ModelStatusHost (models) - model rows, feed
updates; the managers write modalities/status back onto the host's
rows, so those members stay writable
- ConversationsPreferencesHost (conversations) - the active row and
the conversation list
The store classes now declare 'implements <Host>' so the contract is
visible at the class level, and the 'import type { <store> }' back
references in the collaborators disappear entirely - the host
contract is local to each collaborator file, and collaborators can
no longer reach around their slice. Members stay public (structural
typing), but the collaborator side is now compiler-enforced.
* test: Chat Activity store test
* refactor: Cleanup
* chore: Remove legacy architecture docs
* ui: Memoize findMessageIndex for the streaming hot path
Streaming looks up the same message index on every chunk, a linear
scan of activeMessages each time. Cache the last lookup and reuse it
after validating the id still sits at the same position (O(1)); any
structural change to the array fails validation and falls back to a
full scan.
* ui: Throttle per-chunk stream state writes to localStorage
saveStreamState ran JSON.stringify + a synchronous localStorage.setItem
on every decoded chunk of the stream. The read loop now goes through a
new saveStreamStateThrottled (one write per conversation per 500ms,
latest value held pending); the public saveStreamState keeps its
immediate-write contract for stream start and pre-fetch, and also
resets the throttle window.
A pending offset is force-flushed at resume boundaries (resumeStream
reads the offset back from localStorage), on visibilitychange->hidden
and on pagehide, so a reload always finds a usable offset. The resume
offset only needs to be roughly current since the server retransmits
from a line boundary and the client discards its partial line.
Adds unit tests for the throttled/flush/clear interplay.
* ui: Compute context gauge timing stats in one pass
currentRead/Fresh/Cache/Output were separate deriveds, each running a
full reverse scan of activeMessages for the last assistant timings,
and cumulative ran its own forward scan plus an agentic filter - 4-5
O(n) passes per chunk while streaming. Replace with a single
summarizeAssistantTimings() pass (last assistant timings, last
agentic llm totals and the cumulative sums) feeding a shared derived
snapshot. Semantics unchanged, including the live-stats overrides and
the agentic llm-totals branch.
* agentic : clear session state when a conversation is deleted
Every conversation that ran an agentic flow left an AgenticSession in the
store forever; clearSession was never called. conversationsStore now
notifies deletion listeners and agenticStore drops the matching sessions,
avoiding a circular import back into conversationsStore.
* chat : extract ChatService.normalizeMessagesForApi
The DB->API message normalization (convert + drop empty system messages)
was duplicated in sendMessage, preEncode and the agentic flow. Extract it
into one shared method and call it from all three.
* sse : share record splitting and data extraction
splitSseRecords and extractSseDataPayload centralize the record-boundary
splitting and data: line extraction used by parseSseJsonStream and the
models status feed. chat.service keeps its own line-based parser for
resume support.
* api : delegate apiFetchWithParams to apiFetch
apiFetchWithParams duplicated apiFetch's headers/fetch/error handling
body-for-body; it only differs in URL construction. Build the URL and
delegate.
* chat flows : dedupe title, timings and cleanup handling
- conversationsStore.applyTitleFromContent centralizes the title-from-first-
message logic duplicated in 5 places
- ChatProcessingStore.applyStreamTimings centralizes the onTimings handler
shared by the chat and continue flows
- host.cleanupStreaming centralizes the loading/streaming/processing reset
repeated across the continue flow's exit paths
* conversations : centralize conversation update mirroring
rename, pin, mcp override, reasoning effort and cwd all repeated the same
write-DB-then-mirror-into-list-and-active dance. A single
applyConversationUpdate(id, updates) on the host collapses all five and
removes the forgot-to-mirror-one-field bug class. Drops the redundant
array reassignment in setCwd (deep field assignment is reactive).
* mcp : dedupe tool execution, server parsing and tool indexing
- executeTool delegates to executeToolByName (only diff was argument parsing)
- drop the private #parseServerSettings copy; use parseMcpServerSettings
- cache getServers() keyed on the raw config value (hot path)
- indexServerTools() unifies the three identical toolsIndex rebuild loops
Assisted-by: Claude
* mcp : share cursor pagination and tool indexing
- MCPService.paginate() collapses the identical do-while loops in
listAllResources and listAllResourceTemplates
- promoteHealthCheckToConnection now uses indexServerTools like the other
connect paths
Assisted-by: Claude
* database : share message parent-child bookkeeping
- addChildToParent() dedups the append-to-children update in createMessageBranch
and createSystemMessage
- removeChildFromParent() dedups the remove-from-children cleanup in deleteMessage
and deleteMessageCascading
- bulkAdd the cloned messages when forking a conversation instead of one add
per message
Assisted-by: Claude
* chore: Lint/format
* fix: `pagehide` event from `window`
* refactor: Api Fetch util
* docs : rewrite architecture sections in README
Update the high-level diagram, routes, hooks, stores, services and data
flow tables to match the current UI structure (mcp/settings/search
routes, agentic/tools/mcp stores, MCPService/ToolsService/SandboxService,
/tools API). Fix stale architectural patterns for per-conversation state
and modality validation.
* chore : add ESLint rule for blank lines between accessors
Enforce a blank line between consecutive class accessors. The core
padding-line-between-statements rule does not cover class members, so a
local rule is needed.
* refactor : reorder store members and unify naming
Order store class members as public fields, private fields, constructor,
getters, public methods, then private methods. Normalize private naming
to the `private` keyword (drop `#` and the `_` prefix where there is no
matching public getter). Rename conversationsStore.init() to
initialize() to match the other stores.
* refactor : prefix lookup methods with get in agentic and chat stores
Unify bare-name lookup methods with the get* prefix used across the
other stores (mcp, models, tools, settings). Renames currentTurn,
totalToolCalls, lastError, streamingToolCall, executingToolCallId,
pendingPermissionRequest, pendingContinueRequest,
pendingSteeringMessageContent, pendingSteeringMessageExtras in the
agentic store and pendingMessageContent, pendingMessageExtras in the
chat store. Updates the two consuming components and a doc comment.
* refactor: Clean up comments in stores' and services' code
* chore : add ESLint rule for class member ordering
Enforce structural order (public fields -> private fields -> constructor ->
getters -> setters -> public methods -> private methods) with alphabetical
sorting within each group via perfectionist/sort-classes. Dependency
detection keeps Svelte $derived fields in a valid dependency order instead
of alphabetizing them, since Svelte rejects forward references.
Assisted-by: Claude
* refactor : reorder class members to match new ESLint rule
Apply the sort-classes rule across stores, services, hooks and utils.
Pure reordering - verified no logic changes by comparing sorted line
multisets before/after. All tests and svelte-check pass.
730 lines
22 KiB
TypeScript
730 lines
22 KiB
TypeScript
/**
|
|
* conversationsStore - Conversation lifecycle, persistence and navigation
|
|
*
|
|
* Owns conversation CRUD, message tree navigation, import/export and title
|
|
* management, persisted through DatabaseService. Per-chat options (MCP
|
|
* overrides, reasoning effort, cwd) live in ConversationPreferences,
|
|
* composed as {@link ConversationsStore.preferences}.
|
|
*/
|
|
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
import { ROUTES } from '$lib/constants';
|
|
import { MessageRole } from '$lib/enums';
|
|
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
|
|
import { DatabaseService } from '$lib/services/database.service';
|
|
import { MigrationService } from '$lib/services/migration.service';
|
|
import { RouterService } from '$lib/services/router.service';
|
|
// direct imports between stores, not via the barrel, to avoid circular deps
|
|
import {
|
|
ConversationPreferences,
|
|
type ConversationsPreferencesHost
|
|
} from '$lib/stores/conversations/preferences.svelte';
|
|
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
|
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
|
|
class ConversationsStore implements ConversationsPreferencesHost {
|
|
/** Currently active conversation */
|
|
activeConversation = $state<DatabaseConversation | null>(null);
|
|
|
|
/** Messages in the active conversation (filtered by currNode path) */
|
|
activeMessages = $state<DatabaseMessage[]>([]);
|
|
|
|
/** List of all conversations */
|
|
conversations = $state<DatabaseConversation[]>([]);
|
|
|
|
/** Whether the store has been initialized */
|
|
isInitialized = $state(false);
|
|
|
|
/** Per-chat options (MCP overrides, reasoning effort, cwd), composed here. */
|
|
private _preferences = new ConversationPreferences(this);
|
|
|
|
/**
|
|
* Listeners notified with the ids of conversations that were deleted.
|
|
* Lets dependent stores (e.g. agenticStore) drop per-conversation state
|
|
* without introducing a circular import back into this store.
|
|
*/
|
|
private conversationDeletionListeners = new Set<(convIds: string[]) => void>();
|
|
|
|
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
/**
|
|
* Memo of the last findMessageIndex() lookup. Streaming calls it once per
|
|
* chunk for the same message, so a validated cache hit keeps that O(1)
|
|
* instead of a linear scan of activeMessages on every token.
|
|
*/
|
|
private lastMessageIndex: { id: string; index: number } | null = null;
|
|
|
|
get preferences() {
|
|
return this._preferences;
|
|
}
|
|
|
|
/**
|
|
* Adds a message to the active messages array
|
|
*/
|
|
addMessageToActive(message: DatabaseMessage): void {
|
|
this.activeMessages.push(message);
|
|
}
|
|
|
|
/**
|
|
* Applies a field update to a conversation row, mirroring it into both the
|
|
* conversations list and the active conversation when it is the target.
|
|
* Shared by the rename/pin/preferences flows so no caller can forget to
|
|
* mirror one side.
|
|
*/
|
|
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void {
|
|
const convIndex = this.conversations.findIndex((c) => c.id === id);
|
|
|
|
if (convIndex !== -1) {
|
|
const target = this.conversations[convIndex] as unknown as Record<string, unknown>;
|
|
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (target[key] !== value) target[key] = value;
|
|
}
|
|
}
|
|
|
|
if (this.activeConversation?.id === id) {
|
|
this.activeConversation = { ...this.activeConversation, ...updates };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derives a conversation title from its first message content and applies
|
|
* it, honoring the title-generation setting. Shared by every flow that
|
|
* edits or creates the first user message.
|
|
*/
|
|
async applyTitleFromContent(convId: string, content: string): Promise<void> {
|
|
await this.updateConversationName(
|
|
convId,
|
|
generateConversationTitle(content, Boolean(settingsStore.config.titleGenerationUseFirstLine))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Deletes multiple conversations in sequence.
|
|
* Mirrors deleteConversation() per-id; navigates to NEW_CHAT only if the
|
|
* currently-open chat was among the deleted ones.
|
|
* @param convIds - Conversation IDs to delete
|
|
*/
|
|
async bulkDeleteConversations(convIds: string[]): Promise<void> {
|
|
if (convIds.length === 0) return;
|
|
|
|
try {
|
|
const idsToRemove = new SvelteSet(convIds);
|
|
// Collect all descendants recursively so the local cache stays consistent
|
|
// even when deleteWithForks is omitted.
|
|
const queue = [...convIds];
|
|
|
|
while (queue.length > 0) {
|
|
const parentId = queue.pop()!;
|
|
|
|
for (const c of this.conversations) {
|
|
if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
|
|
idsToRemove.add(c.id);
|
|
queue.push(c.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
const activeWasDeleted =
|
|
this.activeConversation !== null && idsToRemove.has(this.activeConversation.id);
|
|
|
|
await DatabaseService.bulkDeleteConversations([...idsToRemove]);
|
|
|
|
this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
|
|
this.notifyConversationsDeleted([...idsToRemove]);
|
|
|
|
if (activeWasDeleted) {
|
|
this.clearActiveConversation();
|
|
await goto(ROUTES.NEW_CHAT);
|
|
}
|
|
|
|
toast.success(
|
|
idsToRemove.size === 1
|
|
? 'Conversation deleted'
|
|
: `${idsToRemove.size} conversations deleted`
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to bulk delete conversations:', error);
|
|
toast.error('Failed to delete conversations');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bundles the given conversations into a single zip archive and triggers a
|
|
* browser download (one JSONL file per conversation).
|
|
* @param convIds - Conversation IDs to export
|
|
*/
|
|
async bulkExportConversations(convIds: string[]): Promise<void> {
|
|
if (convIds.length === 0) return;
|
|
|
|
try {
|
|
const fetched = await DatabaseService.getConversationsWithMessages(convIds);
|
|
const activeId = this.activeConversation?.id;
|
|
const overridden = fetched.get(activeId ?? '');
|
|
|
|
if (overridden && activeId) {
|
|
overridden.conv = { ...this.activeConversation! };
|
|
}
|
|
|
|
const exported = [...fetched.values()];
|
|
|
|
if (exported.length === 0) {
|
|
toast.error('No conversations to export');
|
|
|
|
return;
|
|
}
|
|
|
|
ConversationTransferService.downloadConversationsArchive(exported);
|
|
|
|
toast.success(
|
|
exported.length === 1
|
|
? 'Conversation exported'
|
|
: `${exported.length} conversations exported`
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to bulk export conversations:', error);
|
|
toast.error('Failed to export conversations');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggles the pinned state of each conversation individually.
|
|
* Mixed-pin selections are intentionally not normalised here; the bulk
|
|
* action UI surfaces them as a disabled mixed-state instead.
|
|
* @param convIds - Conversation IDs to toggle
|
|
*/
|
|
async bulkToggleConversationPin(convIds: string[]): Promise<void> {
|
|
if (convIds.length === 0) return;
|
|
|
|
try {
|
|
const updates = await DatabaseService.bulkToggleConversationPins(convIds);
|
|
const activeId = this.activeConversation?.id;
|
|
|
|
if (activeId && updates.has(activeId)) {
|
|
this.activeConversation = {
|
|
...this.activeConversation!,
|
|
pinned: updates.get(activeId)!
|
|
};
|
|
}
|
|
|
|
for (let i = 0; i < this.conversations.length; i++) {
|
|
const newPinned = updates.get(this.conversations[i].id);
|
|
|
|
if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
|
|
}
|
|
|
|
toast.success(
|
|
convIds.length === 1
|
|
? 'Conversation pin toggled'
|
|
: `Updated pin state for ${convIds.length} conversations`
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to bulk toggle pin:', error);
|
|
toast.error('Failed to update pin state');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears the active conversation and messages.
|
|
*/
|
|
clearActiveConversation(): void {
|
|
this.activeConversation = null;
|
|
this.activeMessages = [];
|
|
// reload defaults so new chats inherit persisted state
|
|
this.preferences.resetPending();
|
|
}
|
|
|
|
/**
|
|
* Creates a new conversation and navigates to it
|
|
* @param name - Optional name for the conversation
|
|
* @returns The ID of the created conversation
|
|
*/
|
|
async createConversation(name?: string): Promise<string> {
|
|
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
|
// Working directory and reasoning effort picked on the new-chat screen
|
|
// get threaded into the new conversation here, then cleared so they
|
|
// don't bleed onto subsequent new chats.
|
|
const conversation = await DatabaseService.createConversation(conversationName, {
|
|
cwd: this.preferences.pendingCwd ?? undefined,
|
|
reasoningEffort: this.preferences.pendingReasoningEffort
|
|
});
|
|
|
|
this.preferences.pendingCwd = null;
|
|
|
|
this.conversations = [conversation, ...this.conversations];
|
|
this.activeConversation = conversation;
|
|
this.activeMessages = [];
|
|
|
|
await goto(RouterService.chat(conversation.id));
|
|
|
|
return conversation.id;
|
|
}
|
|
|
|
/**
|
|
* Deletes all conversations and their messages
|
|
*/
|
|
async deleteAll(): Promise<void> {
|
|
try {
|
|
const allConversations = await DatabaseService.getAllConversations();
|
|
const allIds = allConversations.map((c) => c.id);
|
|
|
|
await DatabaseService.bulkDeleteConversations(allIds);
|
|
|
|
this.clearActiveConversation();
|
|
this.conversations = [];
|
|
this.notifyConversationsDeleted(allIds);
|
|
|
|
toast.success('All conversations deleted');
|
|
|
|
await goto(ROUTES.NEW_CHAT);
|
|
} catch (error) {
|
|
console.error('Failed to delete all conversations:', error);
|
|
toast.error('Failed to delete conversations');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes a conversation and all its messages
|
|
* @param convId - The conversation ID to delete
|
|
*/
|
|
async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise<void> {
|
|
try {
|
|
await DatabaseService.deleteConversation(convId, options);
|
|
|
|
if (options?.deleteWithForks) {
|
|
// Collect all descendants recursively
|
|
const idsToRemove = new SvelteSet([convId]);
|
|
const queue = [convId];
|
|
|
|
while (queue.length > 0) {
|
|
const parentId = queue.pop()!;
|
|
|
|
for (const c of this.conversations) {
|
|
if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
|
|
idsToRemove.add(c.id);
|
|
queue.push(c.id);
|
|
}
|
|
}
|
|
}
|
|
this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
|
|
|
|
if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
|
|
this.clearActiveConversation();
|
|
await goto(ROUTES.NEW_CHAT);
|
|
}
|
|
|
|
this.notifyConversationsDeleted([...idsToRemove]);
|
|
} else {
|
|
// Reparent direct children to deleted conv's parent (or promote to top-level)
|
|
const deletedConv = this.conversations.find((c) => c.id === convId);
|
|
const newParent = deletedConv?.forkedFromConversationId;
|
|
|
|
this.conversations = this.conversations
|
|
.filter((c) => c.id !== convId)
|
|
.map((c) =>
|
|
c.forkedFromConversationId === convId
|
|
? { ...c, forkedFromConversationId: newParent }
|
|
: c
|
|
);
|
|
|
|
if (this.activeConversation?.id === convId) {
|
|
this.clearActiveConversation();
|
|
await goto(ROUTES.NEW_CHAT);
|
|
}
|
|
|
|
this.notifyConversationsDeleted([convId]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete conversation:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads a single conversation as a JSONL file, serializing the full message tree.
|
|
* @param convId - The conversation ID to download
|
|
*/
|
|
async downloadConversation(convId: string): Promise<void> {
|
|
const conversation =
|
|
this.activeConversation?.id === convId
|
|
? this.activeConversation
|
|
: await DatabaseService.getConversation(convId);
|
|
|
|
if (!conversation) return;
|
|
|
|
const messages = await DatabaseService.getConversationMessages(convId);
|
|
|
|
ConversationTransferService.downloadConversationFile({ conv: conversation, messages });
|
|
}
|
|
|
|
/**
|
|
* Finds the index of a message in active messages.
|
|
*
|
|
* The last lookup is memoized and reused when it still validates against
|
|
* the current array (same id at the same position), which covers the
|
|
* streaming hot path where the same message is looked up on every chunk
|
|
* while the array itself only mutates by field. Any structural change
|
|
* (splice, reassignment, reordering) fails validation and falls back to a
|
|
* full scan.
|
|
*/
|
|
findMessageIndex(messageId: string): number {
|
|
const last = this.lastMessageIndex;
|
|
const messages = this.activeMessages;
|
|
|
|
if (
|
|
last &&
|
|
last.id === messageId &&
|
|
last.index >= 0 &&
|
|
last.index < messages.length &&
|
|
messages[last.index]?.id === messageId
|
|
) {
|
|
return last.index;
|
|
}
|
|
|
|
const index = messages.findIndex((m) => m.id === messageId);
|
|
|
|
this.lastMessageIndex = { id: messageId, index };
|
|
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* Forks a conversation at a specific message, creating a new conversation
|
|
* containing messages from root up to the target message, then navigates to it.
|
|
*
|
|
* @param messageId - The message ID to fork at
|
|
* @param options - Fork options (name and whether to include attachments)
|
|
* @returns The new conversation ID, or null if fork failed
|
|
*/
|
|
async forkConversation(
|
|
messageId: string,
|
|
options: { name: string; includeAttachments: boolean }
|
|
): Promise<string | null> {
|
|
if (!this.activeConversation) return null;
|
|
|
|
try {
|
|
const newConv = await DatabaseService.forkConversation(
|
|
this.activeConversation.id,
|
|
messageId,
|
|
options
|
|
);
|
|
|
|
this.conversations = [newConv, ...this.conversations];
|
|
|
|
await goto(RouterService.chat(newConv.id));
|
|
|
|
toast.success('Conversation forked');
|
|
|
|
return newConv.id;
|
|
} catch (error) {
|
|
console.error('Failed to fork conversation:', error);
|
|
toast.error('Failed to fork conversation');
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets all messages for a specific conversation
|
|
* @param convId - The conversation ID
|
|
* @returns Array of messages
|
|
*/
|
|
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
|
return await DatabaseService.getConversationMessages(convId);
|
|
}
|
|
|
|
/**
|
|
* Imports conversations from provided data (without file picker)
|
|
* @param data - Array of conversation data with messages
|
|
* @returns The conversations written to the database and the ones skipped
|
|
*/
|
|
async importConversationsData(
|
|
data: ExportedConversations
|
|
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
|
|
const result = await DatabaseService.importConversations(data);
|
|
|
|
await this.loadConversations();
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Initialize the store by loading conversations from database.
|
|
* Safe to call multiple times: concurrent callers share a single run,
|
|
* and a failed run can be retried by calling again.
|
|
*/
|
|
initialize(): Promise<void> {
|
|
if (!browser) return Promise.resolve();
|
|
|
|
if (this.initPromise) return this.initPromise;
|
|
|
|
this.initPromise = (async () => {
|
|
try {
|
|
await MigrationService.runAllMigrations();
|
|
await this.loadConversations();
|
|
this.isInitialized = true;
|
|
} catch (error) {
|
|
console.error('Failed to initialize conversations:', error);
|
|
this.initPromise = null;
|
|
}
|
|
})();
|
|
|
|
return this.initPromise;
|
|
}
|
|
|
|
/**
|
|
* Loads a specific conversation and its messages
|
|
* @param convId - The conversation ID to load
|
|
* @returns True if conversation was loaded successfully
|
|
*/
|
|
async loadConversation(convId: string): Promise<boolean> {
|
|
try {
|
|
const conversation = await DatabaseService.getConversation(convId);
|
|
|
|
if (!conversation) {
|
|
return false;
|
|
}
|
|
|
|
// Drop any cwd the user drafted on the empty new-chat screen -
|
|
// it doesn't belong to this conversation.
|
|
this.preferences.pendingCwd = null;
|
|
|
|
this.activeConversation = conversation;
|
|
|
|
if (conversation.currNode) {
|
|
const allMessages = await DatabaseService.getConversationMessages(convId);
|
|
const filteredMessages = filterByLeafNodeId(
|
|
allMessages,
|
|
conversation.currNode,
|
|
false
|
|
) as DatabaseMessage[];
|
|
|
|
this.activeMessages = filteredMessages;
|
|
} else {
|
|
const messages = await DatabaseService.getConversationMessages(convId);
|
|
|
|
this.activeMessages = messages;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to load conversation:', error);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads all conversations from the database
|
|
*/
|
|
async loadConversations(): Promise<void> {
|
|
const conversations = await DatabaseService.getAllConversations();
|
|
|
|
this.conversations = conversations;
|
|
}
|
|
|
|
/**
|
|
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
|
|
* @param siblingId - The sibling message ID to navigate to
|
|
*/
|
|
async navigateToSibling(siblingId: string): Promise<void> {
|
|
if (!this.activeConversation) return;
|
|
|
|
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
|
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
|
const currentFirstUserMessage = this.activeMessages.find(
|
|
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
|
|
);
|
|
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
|
|
|
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
|
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
|
|
await this.refreshActiveMessages();
|
|
|
|
if (rootMessage && this.activeMessages.length > 0) {
|
|
const newFirstUserMessage = this.activeMessages.find(
|
|
(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
|
|
);
|
|
|
|
if (
|
|
newFirstUserMessage &&
|
|
newFirstUserMessage.content.trim() &&
|
|
(!currentFirstUserMessage ||
|
|
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
|
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
|
) {
|
|
await this.applyTitleFromContent(this.activeConversation.id, newFirstUserMessage.content);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registers a listener invoked with the ids of deleted conversations.
|
|
* Returns an unsubscribe function.
|
|
*/
|
|
onConversationsDeleted(listener: (convIds: string[]) => void): () => void {
|
|
this.conversationDeletionListeners.add(listener);
|
|
|
|
return () => this.conversationDeletionListeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* Refreshes active messages based on currNode after branch navigation.
|
|
*/
|
|
async refreshActiveMessages(): Promise<void> {
|
|
if (!this.activeConversation) return;
|
|
|
|
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
|
|
|
if (allMessages.length === 0) {
|
|
this.activeMessages = [];
|
|
|
|
return;
|
|
}
|
|
|
|
const leafNodeId =
|
|
this.activeConversation.currNode ||
|
|
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
|
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
|
|
|
this.activeMessages = currentPath;
|
|
}
|
|
|
|
/**
|
|
* Removes a message from active messages by index
|
|
*/
|
|
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
|
if (index !== -1) {
|
|
return this.activeMessages.splice(index, 1)[0];
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Removes messages from active messages starting at an index
|
|
*/
|
|
sliceActiveMessages(startIndex: number): void {
|
|
this.activeMessages = this.activeMessages.slice(0, startIndex);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
this.applyConversationUpdate(convId, { pinned: newPinnedState });
|
|
|
|
return newPinnedState;
|
|
} catch (error) {
|
|
console.error('Failed to toggle conversation pin:', error);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the name of a conversation.
|
|
* @param convId - The conversation ID to update
|
|
* @param name - The new name for the conversation
|
|
*/
|
|
async updateConversationName(convId: string, name: string): Promise<void> {
|
|
try {
|
|
await DatabaseService.updateConversation(convId, { name });
|
|
|
|
this.applyConversationUpdate(convId, { name });
|
|
} catch (error) {
|
|
console.error('Failed to update conversation name:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks a conversation as recently active: stamps lastModified (persisted)
|
|
* and moves it to the top of the list. Only message-activity flows call
|
|
* this; metadata updates (rename, pin, settings) do not.
|
|
*
|
|
* @param convId - Conversation that produced the activity, defaults to the active one
|
|
*/
|
|
updateConversationTimestamp(convId?: string): void {
|
|
const targetId = convId ?? this.activeConversation?.id;
|
|
|
|
if (!targetId) return;
|
|
|
|
const now = Date.now();
|
|
const chatIndex = this.conversations.findIndex((c) => c.id === targetId);
|
|
|
|
if (chatIndex !== -1) {
|
|
this.conversations[chatIndex].lastModified = now;
|
|
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
|
|
|
this.conversations = [updatedConv, ...this.conversations];
|
|
}
|
|
|
|
if (this.activeConversation?.id === targetId) {
|
|
this.activeConversation = { ...this.activeConversation, lastModified: now };
|
|
}
|
|
|
|
DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
|
|
console.error('Failed to update conversation timestamp:', error)
|
|
);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Import & Export
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Updates the current node of the active conversation
|
|
* @param nodeId - The new current node ID
|
|
*/
|
|
async updateCurrentNode(nodeId: string): Promise<void> {
|
|
if (!this.activeConversation) return;
|
|
|
|
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
|
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
|
|
}
|
|
|
|
/**
|
|
* Updates a message at a specific index in active messages
|
|
*/
|
|
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
|
const message = index === -1 ? undefined : this.activeMessages[index];
|
|
|
|
if (!message) return;
|
|
|
|
// Assign field by field rather than replacing the object. Replacing it
|
|
// changes the array slot, which invalidates every consumer that merely
|
|
// walks the list - notably ChatMessages.displayMessages, which rebuilds
|
|
// entries for every message in the conversation. Deep $state proxies make
|
|
// per-field writes fine-grained, so only readers of the changed field wake.
|
|
const target = message as unknown as Record<string, unknown>;
|
|
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (target[key] !== value) {
|
|
target[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
private notifyConversationsDeleted(convIds: string[]): void {
|
|
if (convIds.length === 0) return;
|
|
|
|
for (const listener of this.conversationDeletionListeners) {
|
|
listener(convIds);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const conversationsStore = new ConversationsStore();
|