/**
 * chatStore - Reactive State Store for Chat Operations
 *
 * Manages chat lifecycle, streaming, message operations, and processing state.
 *
 * **Architecture & Relationships:**
 * - **ChatService**: Stateless API layer (sendMessage, streaming)
 * - **chatStore** (this): Reactive state + business logic
 * - **conversationsStore**: Conversation persistence and navigation
 *
 * @see ChatService in services/chat.service.ts for API operations
 */

import {
	CONVERSATION_ID_SEPARATOR,
	CWD_CLEARED_TEXT,
	HEADERS,
	INACTIVE_CONVERSATION,
	STREAM_RESUME_RETRY_MS,
	SYSTEM_MESSAGE_PLACEHOLDER,
	TITLE_GENERATION
} from '$lib/constants';
import {
	ContinueIntentKind,
	ErrorDialogType,
	MessageRole,
	MessageType,
	MimeTypeApplication,
	ReasoningEffort,
	StreamConnectionState
} from '$lib/enums';
import { ChatService } from '$lib/services/chat.service';
import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { agenticStore } from '$lib/stores/agentic.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type {
	ApiChatMessageData,
	ApiProcessingState,
	ApiStreamSession,
	ChatMessagePromptProgress,
	ChatMessageTimings,
	ChatStreamCallbacks,
	DatabaseMessage,
	DatabaseMessageExtra,
	ErrorDialogState
} from '$lib/types';
import {
	classifyContinueIntent,
	filterByLeafNodeId,
	findDescendantMessages,
	findLeafNode,
	findMessageById,
	formatCwdMessage,
	generateConversationTitle,
	getAuthHeaders,
	isAbortError,
	normalizeModelName,
	streamIdentity
} from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';

interface ConversationStateEntry {
	lastAccessed: number;
}

class ChatStore {
	activeProcessingState = $state<ApiProcessingState | null>(null);
	currentResponse = $state('');
	errorDialogState = $state<ErrorDialogState | null>(null);
	isLoading = $state(false);
	// true while the active conversation streams reasoning content but no visible content yet
	isReasoning = $state(false);
	// resumable stream connection state for the active conversation
	// streaming -> bytes flowing normally, resuming -> waiting on /v1/stream reconnect, lost -> unrecoverable
	streamConnectionState = $state<StreamConnectionState>(StreamConnectionState.STREAMING);
	chatLoadingStates = new SvelteMap<string, boolean>();
	chatReasoningStates = new SvelteMap<string, boolean>();
	chatStreamingStates = new SvelteMap<
		string,
		{ response: string; messageId: string; model?: string | null }
	>();
	// convs that the backend reports as having a running session, populated by the global sync
	// at app mount and on visibilitychange. it does not overlap with chatLoadingStates which
	// tracks inferences driven by this browser, both are unioned to feed the sidebar spinners
	private remoteRunningConvs = new SvelteSet<string>();
	// per conv attach lifecycle, used to derive the global streaming flag without flipping it
	// off when one conv finishes while another is still streaming. mirrors chatLoadingStates
	// in scope but tracks the attach + tee replay path specifically
	private attachingConvs = new SvelteSet<string>();
	// pending resume retry timers while an owning model loads, one per conv
	private resumeRetryTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
	// convs whose resume waits on a model load: their loading state belongs to the retry loop,
	// so discoverActiveStream must not treat it as a live send and bail
	private resumePendingConvs = new SvelteSet<string>();
	// in-flight discoverActiveStream guard, keyed by conv id
	private discoveringConvs = new SvelteSet<string>();
	private abortControllers = new SvelteMap<string, AbortController>();
	private preEncodeAbortController: AbortController | null = null;
	private processingStates = new SvelteMap<string, ApiProcessingState | null>();
	private conversationStateTimestamps = new SvelteMap<string, ConversationStateEntry>();
	private activeConversationId = $state<string | null>(null);
	private isStreamingActive = $state(false);
	private isEditModeActive = $state(false);
	private addFilesHandler: ((files: File[]) => void) | null = $state(null);
	pendingEditMessageId = $state<string | null>(null);
	private messageUpdateCallback:
		| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
		| null = null;
	private _pendingDraftMessage = $state<string>('');
	private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);

	/** Reactive: queued pending messages for non-agentic streaming */
	private _pendingMessages = new SvelteMap<
		string,
		{ content: string; extras?: DatabaseMessageExtra[] }
	>();

	private setChatLoading(convId: string, loading: boolean): void {
		this.touchConversationState(convId);

		if (loading) {
			this.chatLoadingStates.set(convId, true);

			if (convId === conversationsStore.activeConversation?.id) this.isLoading = true;
		} else {
			this.chatLoadingStates.delete(convId);

			if (convId === conversationsStore.activeConversation?.id) this.isLoading = false;

			this.setChatReasoning(convId, false);
			// the local pipe is the authoritative observer of session end: when it finishes (clean
			// onComplete or explicit Stop), the backend session is finalized too, so we drop the
			// sidebar hint for this conv right away instead of waiting for the next visibilitychange
			// snapshot. without this the spinner ghosts until the user toggles the tab
			this.remoteRunningConvs.delete(convId);
		}
	}

	private setChatReasoning(convId: string, reasoning: boolean): void {
		if (reasoning) {
			this.chatReasoningStates.set(convId, true);

			if (convId === conversationsStore.activeConversation?.id) this.isReasoning = true;
		} else {
			this.chatReasoningStates.delete(convId);

			if (convId === conversationsStore.activeConversation?.id) this.isReasoning = false;
		}
	}
	private setChatStreaming(
		convId: string,
		response: string,
		messageId: string,
		model?: string | null
	): void {
		this.touchConversationState(convId);
		this.chatStreamingStates.set(convId, {
			messageId,
			model: model ?? this.chatStreamingStates.get(convId)?.model,
			response
		});

		if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
	}
	private clearChatStreaming(convId: string, messageId?: string): void {
		// session aware: a stale generation must not wipe a newer one's streaming state on the
		// same conversation, that would drop the frozen stop identity and stop the wrong session
		if (messageId !== undefined) {
			const cur = this.chatStreamingStates.get(convId);

			if (cur && cur.messageId !== messageId) return;
		}

		this.chatStreamingStates.delete(convId);

		if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
	}
	private getChatStreamingState(
		convId: string
	): { response: string; messageId: string } | undefined {
		return this.chatStreamingStates.get(convId);
	}
	syncLoadingStateForChat(convId: string): void {
		this.isLoading = this.chatLoadingStates.get(convId) || false;
		this.isReasoning = this.chatReasoningStates.get(convId) || false;
		const s = this.chatStreamingStates.get(convId);

		this.currentResponse = s?.response || '';
		this.isStreamingActive = s !== undefined;
		this.setActiveProcessingConversation(convId);

		// Sync streaming content to activeMessages so UI displays current content
		if (s?.response && s?.messageId) {
			const idx = conversationsStore.findMessageIndex(s.messageId);

			if (idx !== -1) {
				conversationsStore.updateMessageAtIndex(idx, { content: s.response });
			}
		}
	}
	/**
	 * Server side stream discovery, split in three pieces:
	 *
	 * probeServerStream(convId) -> hits POST /v1/streams/lookup with the conv id, returns the session to attach
	 *   to or null. Pure read, no side effect, no UI lock. Safe to fire in parallel with anything.
	 *
	 * attachServerStream(convId) -> flips the spinner immediately, fetches the replay stream
	 *   from byte 0, finds the assistant slot to splice into (creates a placeholder if the conv has
	 *   no assistant message yet, for cross device or fresh local DB cases), and pipes the SSE bytes
	 *   into the message via handleStreamResponse.
	 *
	 * discoverActiveStream(convId) -> probe + attach in one call. Used by callers that do not need
	 *   to overlap the probe with other async work.
	 *
	 * The mount of the chat page in +page.svelte calls probeServerStream in parallel with
	 * loadConversation, then attachServerStream once both have settled. This gives the earliest
	 * possible time to spinner and avoids racing against an empty activeMessages array.
	 */
	async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
		if (!convId) return null;

		let listResp: Response;

		try {
			// POST the one conv id we are probing
			listResp = await fetch(`./v1/streams/lookup`, {
				body: JSON.stringify({ conversation_ids: [convId] }),
				headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
				method: 'POST'
			});
		} catch (e) {
			console.warn('probeServerStream fetch failed:', e);

			return null;
		}

		if (!listResp.ok) {
			console.warn(`probeServerStream got HTTP ${listResp.status} for conv ${convId}`);

			return null;
		}

		let sessions: ApiStreamSession[];

		try {
			sessions = (await listResp.json()) as ApiStreamSession[];
		} catch (e) {
			console.warn('probeServerStream JSON parse failed:', e);

			return null;
		}

		return ChatService.selectActiveStream(sessions);
	}

	async attachServerStream(convId: string, streamId?: string): Promise<void> {
		if (!convId) return;

		if (this.chatStreamingStates.has(convId)) return;

		// flip the spinner immediately, the user sees activity as soon as the conv becomes active.
		// the global isStreamingActive flag is derived from attachingConvs.size, so adding here
		// turns it on, and removing in unlock only turns it off when this is the last attach
		this.setChatLoading(convId, true);
		this.attachingConvs.add(convId);
		this.setStreamingActive(true);

		// only set the active processing conv if we are looking at it, otherwise a background
		// attach would steal the indicator from the conv the user is currently viewing
		if (convId === conversationsStore.activeConversation?.id) {
			this.setActiveProcessingConversation(convId);
		}

		const unlock = () => {
			this.attachingConvs.delete(convId);

			// flip the global flag off only when no other conv is still attaching
			if (this.attachingConvs.size === 0) {
				this.setStreamingActive(false);
			}

			this.setChatLoading(convId, false);
			this.clearChatStreaming(convId);
		};
		// fetch the replay stream from byte 0, rebuild the assistant message from scratch.
		// resolve the server side identity, fall back to streamIdentity when the caller does not
		// pass a streamId. probeServerStream returns the full id (with ::model suffix when present)
		const id = streamId || streamIdentity(convId, modelsStore.selectedModelName);

		let response: Response;

		try {
			response = await fetch(`./v1/stream?conv_id=${encodeURIComponent(id)}&from=0`, {
				headers: getAuthHeaders()
			});
		} catch (e) {
			console.error('attachServerStream replay fetch failed:', e);
			unlock();

			return;
		}

		if (!response.ok) {
			console.warn(`attachServerStream replay got HTTP ${response.status} for conv ${convId}`);
			unlock();

			return;
		}

		// load the target conversation messages by id, not via the active store. when multiple
		// attaches run in parallel the active store may reflect another conv and writing through
		// its index mixes content across convs (CoT flicker, message bleed). by going through the
		// DB we stay isolated, and only mirror into the active store when the attached conv is
		// the one currently displayed
		let messages: DatabaseMessage[];

		try {
			messages = await DatabaseService.getConversationMessages(convId);
		} catch (e) {
			console.error('attachServerStream load messages failed:', e);
			unlock();

			return;
		}

		// locate the slot to splice into, create a placeholder assistant message if there is none.
		// we use the conv-scoped findLastAssistantIdx helpers, they only depend on the array
		let targetIdx = this.findLastAssistantIdx(messages);

		if (targetIdx === -1) {
			const lastUserIdx = this.findLastUserIdx(messages);

			if (lastUserIdx === -1) {
				console.warn(
					`attachServerStream: conv ${convId} has no user or assistant message, cannot splice`
				);
				unlock();

				return;
			}

			try {
				const placeholder = await DatabaseService.createMessageBranch(
					{
						children: [],
						content: '',
						convId,
						parent: messages[lastUserIdx].id,
						role: MessageRole.ASSISTANT,
						timestamp: Date.now(),
						toolCalls: '',
						type: MessageType.TEXT
					} as Omit<DatabaseMessage, 'id'>,
					messages[lastUserIdx].id
				);

				messages = [...messages, placeholder];
				targetIdx = messages.length - 1;

				// only push into the active store when this conv is the one displayed right now
				if (convId === conversationsStore.activeConversation?.id) {
					conversationsStore.addMessageToActive(placeholder);
				}
			} catch (e) {
				console.error('attachServerStream placeholder creation failed:', e);
				unlock();

				return;
			}
		}

		if (targetIdx === -1) {
			unlock();

			return;
		}

		const targetMessage = messages[targetIdx];
		const targetMessageId = targetMessage.id;
		// when the assistant slot already has content, the running session is a continue or
		// another append flow and its buffer holds only the appended deltas. preserve the prefix
		// and let the replay add to it. when the slot is empty the session buffer holds the whole
		// message so we wipe and rebuild from byte 0
		const existingContent = targetMessage.content ?? '';
		const existingReasoning = targetMessage.reasoningContent ?? '';
		const isAppendMode = existingContent.length > 0;
		// helper: write to the active store only when the attached conv is currently displayed.
		// the lookup by message id is robust to reordering of activeMessages, two parallel attaches
		// can no longer step on each other's indices
		const writeActive = (updates: Partial<DatabaseMessage>) => {
			if (convId !== conversationsStore.activeConversation?.id) {
				return;
			}

			const liveIdx = conversationsStore.findMessageIndex(targetMessageId);

			if (liveIdx === -1) return;

			conversationsStore.updateMessageAtIndex(liveIdx, updates);
		};

		if (!isAppendMode) {
			writeActive({ content: '', reasoningContent: undefined });
		}

		// extract the model suffix, the resume calls in handleStreamResponse must reuse the model
		// the session was tagged with, not the live dropdown
		const sepIdx = id.indexOf(CONVERSATION_ID_SEPARATOR);
		const attachedModel: string | null = sepIdx === -1 ? null : id.slice(sepIdx + 2);

		this.setChatStreaming(convId, existingContent, targetMessageId, attachedModel);
		const abortController = this.getOrCreateAbortController(convId);

		let streamedContent = '';
		let streamedReasoningContent = '';

		const cleanup = () => {
			unlock();
			this.setProcessingState(convId, null);
		};

		try {
			await ChatService.handleStreamResponse(
				response,
				(chunk: string) => {
					streamedContent += chunk;
					const displayed = isAppendMode ? existingContent + streamedContent : streamedContent;

					writeActive({ content: displayed });
					this.setChatStreaming(convId, displayed, targetMessageId);
				},
				async (
					finalContent?: string,
					reasoningContent?: string,
					timings?: ChatMessageTimings,
					toolCalls?: string
				) => {
					const streamed = streamedContent || finalContent || '';
					const streamedR = streamedReasoningContent || reasoningContent || '';
					const content = isAppendMode ? existingContent + streamed : streamed;
					const reasoning = isAppendMode ? existingReasoning + streamedR : streamedR;

					// the DB write is the source of truth, mirror to the active store only when
					// the conv is currently displayed
					await DatabaseService.updateMessage(targetMessageId, {
						content,
						reasoningContent: reasoning || undefined,
						timings,
						toolCalls: toolCalls || ''
					});
					writeActive({
						content,
						reasoningContent: reasoning || undefined,
						timings
					});
					cleanup();
				},
				(err: Error) => {
					console.error('attachServerStream pipe error:', err);
					cleanup();
				},
				(chunk: string) => {
					streamedReasoningContent += chunk;
					const displayed = isAppendMode
						? existingReasoning + streamedReasoningContent
						: streamedReasoningContent;

					writeActive({ reasoningContent: displayed });
				},
				undefined,
				undefined,
				undefined,
				undefined,
				convId,
				abortController.signal,
				(connState: StreamConnectionState) => {
					if (convId === conversationsStore.activeConversation?.id) {
						this.streamConnectionState = connState;
					}
				},
				attachedModel
			);
		} catch (e) {
			console.error('attachServerStream pipe crashed:', e);
			cleanup();
		}
	}

	/**
	 * Model frozen at send time for a stream awaiting resume, from the persisted stream state.
	 * The load progress indicator targets it after a reload, when the message row has no model
	 * yet and the dropdown selection may not be restored.
	 */
	getResumeModel(convId: string): string | null {
		return ChatService.getStreamState(convId)?.model ?? null;
	}

	async discoverActiveStream(convId: string): Promise<void> {
		if (!convId) return;

		if (this.chatStreamingStates.has(convId)) return;

		if (this.chatLoadingStates.get(convId) && !this.resumePendingConvs.has(convId)) return;

		// concurrency guard: another discover may already be running for this conv (typical race
		// between mount and visibilitychange on tab switch). a second concurrent fetch on the same
		// /v1/stream would duplicate every byte into the DB message, this guard bounces it
		if (this.discoveringConvs.has(convId)) return;

		this.discoveringConvs.add(convId);

		try {
			// the model is frozen at POST time, rebuild the exact conv::model identity from the
			// persisted state so the lookup key matches what the server stored. null means a single
			// model conv with no ::suffix, only guess from the dropdown with no persisted state
			const localState = ChatService.getStreamState(convId);
			const streamId = ChatService.resumeStreamIdentity(
				convId,
				localState,
				modelsStore.selectedModelName
			);
			// primary path: ask the server which sessions exist for this identity
			const serverTarget = await this.probeServerStream(streamId);

			if (serverTarget) {
				// pass the full server side identity (may carry a ::model suffix) so the GET routes
				// straight to the owning session, no probe or fan out
				await this.attachServerStream(convId, serverTarget.conversation_id);

				return;
			}

			// fallback: local state remembers an interrupted byte offset for this conv, the server may
			// still have a live session matching that identity (we just lost the bytes mid stream). retry
			// with the frozen identity, the server probe inside attachServerStream tells us if it exists
			if (!localState) {
				return;
			}

			// quiet status probe first: a full attach flips the loading UI on every try, probing
			// keeps the retry loop invisible while the owning model is still loading (503)
			const status = await ChatService.probeResumeStatus(streamId);

			if (status === 503) {
				// make the wait visible: the empty assistant row persisted at send time renders
				// the processing info, whose model load percentage flows from the models feed
				this.resumePendingConvs.add(convId);
				this.setChatLoading(convId, true);

				if (!this.resumeRetryTimers.has(convId)) {
					this.resumeRetryTimers.set(
						convId,
						setTimeout(() => {
							this.resumeRetryTimers.delete(convId);
							void this.discoverActiveStream(convId);
						}, STREAM_RESUME_RETRY_MS)
					);
				}

				return;
			}

			if (this.resumePendingConvs.delete(convId) && status !== 200) {
				// the wait is over without a session to attach, drop the visible loading state
				this.setChatLoading(convId, false);
			}

			if (status === 0) {
				// transient network failure, the next mount or visibility change retries
				return;
			}

			if (status !== 200) {
				// the session is gone (stopped, TTL expired), nothing to resume anymore
				ChatService.clearStreamState(convId);

				return;
			}

			await this.attachServerStream(convId, streamId);

			// if attachServerStream failed (session gone, TTL expired), clear the local state to avoid retrying forever
			if (!this.chatStreamingStates.has(convId) && !this.chatLoadingStates.get(convId)) {
				ChatService.clearStreamState(convId);
			}
		} finally {
			this.discoveringConvs.delete(convId);
		}
	}

	private findLastAssistantIdx(messages: DatabaseMessage[]): number {
		for (let i = messages.length - 1; i >= 0; i--) {
			if (messages[i].role === MessageRole.ASSISTANT) return i;
		}

		return -1;
	}

	private findLastUserIdx(messages: DatabaseMessage[]): number {
		for (let i = messages.length - 1; i >= 0; i--) {
			if (messages[i].role === MessageRole.USER) return i;
		}

		return -1;
	}

	clearUIState(): void {
		this.isLoading = false;
		this.currentResponse = '';
		this.isStreamingActive = false;
	}

	setActiveProcessingConversation(conversationId: string | null): void {
		this.activeConversationId = conversationId;
		this.activeProcessingState = conversationId
			? this.processingStates.get(conversationId) || null
			: null;
	}

	getProcessingState(conversationId: string): ApiProcessingState | null {
		return this.processingStates.get(conversationId) || null;
	}

	private setProcessingState(conversationId: string, state: ApiProcessingState | null): void {
		if (state === null) this.processingStates.delete(conversationId);
		else this.processingStates.set(conversationId, state);

		if (conversationId === this.activeConversationId) this.activeProcessingState = state;
	}

	clearProcessingState(conversationId: string): void {
		this.processingStates.delete(conversationId);

		if (conversationId === this.activeConversationId) this.activeProcessingState = null;
	}

	getActiveProcessingState(): ApiProcessingState | null {
		return this.activeProcessingState;
	}

	getCurrentProcessingStateSync(): ApiProcessingState | null {
		return this.activeProcessingState;
	}

	private setStreamingActive(active: boolean): void {
		this.isStreamingActive = active;
	}

	isStreaming(): boolean {
		return this.isStreamingActive;
	}

	private getOrCreateAbortController(convId: string): AbortController {
		let c = this.abortControllers.get(convId);

		if (!c || c.signal.aborted) {
			c = new AbortController();
			this.abortControllers.set(convId, c);
		}

		return c;
	}

	private abortRequest(convId?: string): void {
		if (convId) {
			const c = this.abortControllers.get(convId);

			if (c) {
				c.abort();
				this.abortControllers.delete(convId);
			}
		} else {
			for (const c of this.abortControllers.values()) c.abort();
			this.abortControllers.clear();
		}
	}

	/**
	 * Abort the current agentic flow signal without clearing loading state.
	 * Used by "Send immediately" to force the agentic loop to exit so that
	 * the pending steering message can be re-sent.
	 *
	 * Any tool calls captured mid-stream are dropped before the abort so the
	 * pending message (or a manual follow-up) does not re-send a half-received
	 * tool call with invalid JSON arguments to the server. Mirrors what the
	 * Stop button already does through stopGenerationForChat.
	 */
	async abortCurrentFlow(convId: string): Promise<void> {
		await this.savePartialResponseIfNeeded(convId);
		const c = this.abortControllers.get(convId);

		if (c) {
			c.abort();
			this.abortControllers.delete(convId);
		}
	}

	private showErrorDialog(state: ErrorDialogState | null): void {
		this.errorDialogState = state;
	}

	dismissErrorDialog(): void {
		this.errorDialogState = null;
	}

	clearEditMode(): void {
		this.isEditModeActive = false;
		this.addFilesHandler = null;
	}

	isEditing(): boolean {
		return this.isEditModeActive;
	}

	setEditModeActive(handler: (files: File[]) => void): void {
		this.isEditModeActive = true;
		this.addFilesHandler = handler;
	}

	getAddFilesHandler(): ((files: File[]) => void) | null {
		return this.addFilesHandler;
	}

	clearPendingEditMessageId(): void {
		this.pendingEditMessageId = null;
	}

	savePendingDraft(message: string, files: ChatUploadedFile[]): void {
		this._pendingDraftMessage = message;
		this._pendingDraftFiles = [...files];
	}

	consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
		if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) return null;

		const d = { files: [...this._pendingDraftFiles], message: this._pendingDraftMessage };

		this._pendingDraftMessage = '';
		this._pendingDraftFiles = [];

		return d;
	}

	hasPendingDraft(): boolean {
		return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0;
	}

	getAllLoadingChats(): string[] {
		// union of local (this browser is piping) and remote (backend reports a running session
		// for this conv but no local pipe yet) sources. the sidebar shows one spinner per entry
		const out = new SvelteSet<string>(this.chatLoadingStates.keys());

		for (const id of this.remoteRunningConvs) {
			out.add(id);
		}

		return Array.from(out);
	}

	getAllStreamingChats(): string[] {
		return Array.from(this.chatStreamingStates.keys());
	}

	/**
	 * Resync the remote running convs set from the backend. Called by the layout at mount and on
	 * visibilitychange, no polling. A snapshot semantic: the set is replaced wholesale, stale entries
	 * for sessions that finalized while the browser was elsewhere are dropped naturally.
	 */
	async syncRemoteRunningStreams(): Promise<void> {
		// the conversations store loads from IndexedDB asynchronously, the +layout onMount caller
		// fires before that finishes. read ids straight from the DB so the result does not depend
		// on the store init race, and the sidebar spinners light up at first paint for every conv
		// the user owns even if it has not been hydrated into the store yet
		let ids: string[];

		try {
			const all = await DatabaseService.getAllConversations();

			ids = all.map((c) => c.id).filter((id) => !!id);
		} catch (e) {
			console.warn('syncRemoteRunningStreams DB read failed:', e);

			return;
		}

		// only ask about conv ids the user already owns
		if (ids.length === 0) {
			for (const id of Array.from(this.remoteRunningConvs)) {
				this.remoteRunningConvs.delete(id);
			}

			return;
		}

		// rebuild the frozen conv::model identity per conv so a session started with a model still
		// matches. the server response is mapped back to the bare id below for the sidebar set
		const lookupIds = ids.map((id) =>
			ChatService.resumeStreamIdentity(id, ChatService.getStreamState(id), null)
		);

		let sessions: ApiStreamSession[];

		try {
			const resp = await fetch('./v1/streams/lookup', {
				body: JSON.stringify({ conversation_ids: lookupIds }),
				headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
				method: 'POST'
			});

			if (!resp.ok) return;

			const body = (await resp.json()) as unknown;

			if (!Array.isArray(body)) return;

			sessions = body as ApiStreamSession[];
		} catch (e) {
			console.warn('syncRemoteRunningStreams fetch failed:', e);

			return;
		}
		const running = new SvelteSet<string>();

		for (const s of sessions) {
			if (s && !s.is_done && typeof s.conversation_id === 'string' && s.conversation_id) {
				// strip the optional ::model suffix, the sidebar set is keyed by the bare conv id
				const sepIdx = s.conversation_id.indexOf(CONVERSATION_ID_SEPARATOR);
				const bareId = sepIdx === -1 ? s.conversation_id : s.conversation_id.slice(0, sepIdx);

				running.add(bareId);
			}
		}
		for (const id of Array.from(this.remoteRunningConvs)) {
			if (!running.has(id)) {
				this.remoteRunningConvs.delete(id);
			}
		}
		for (const id of running) {
			this.remoteRunningConvs.add(id);
		}
	}

	getChatStreaming(convId: string): { response: string; messageId: string } | undefined {
		return this.getChatStreamingState(convId);
	}

	isChatLoading(convId: string): boolean {
		return this.chatLoadingStates.get(convId) || false;
	}

	private isChatLoadingInternal(convId: string): boolean {
		return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId);
	}

	hasPendingMessage(convId: string): boolean {
		return this._pendingMessages.has(convId);
	}

	pendingMessageContent(convId: string): string | null {
		return this._pendingMessages.get(convId)?.content ?? null;
	}

	pendingMessageExtras(convId: string): DatabaseMessageExtra[] | undefined {
		return this._pendingMessages.get(convId)?.extras;
	}

	injectPendingMessage(convId: string, content: string, extras?: DatabaseMessageExtra[]): void {
		this._pendingMessages.set(convId, { content, extras });
	}

	clearPendingMessage(convId: string): void {
		this._pendingMessages.delete(convId);
	}

	consumePendingMessage(
		convId: string
	): { content: string; extras?: DatabaseMessageExtra[] } | null {
		const msg = this._pendingMessages.get(convId);

		if (!msg) return null;

		this._pendingMessages.delete(convId);

		return msg;
	}

	private touchConversationState(convId: string): void {
		this.conversationStateTimestamps.set(convId, { lastAccessed: Date.now() });
	}

	cleanupOldConversationStates(activeConversationIds?: string[]): number {
		const now = Date.now();
		const activeIdsList = activeConversationIds ?? [];
		const preserveIds = this.activeConversationId
			? [...activeIdsList, this.activeConversationId]
			: activeIdsList;
		const allConvIds = [
			...new Set([
				...this.chatLoadingStates.keys(),
				...this.chatStreamingStates.keys(),
				...this.abortControllers.keys(),
				...this.processingStates.keys(),
				...this.conversationStateTimestamps.keys()
			])
		];
		const cleanupCandidates: Array<{ convId: string; lastAccessed: number }> = [];

		for (const convId of allConvIds) {
			if (preserveIds.includes(convId)) continue;

			if (this.chatLoadingStates.get(convId)) continue;

			if (this.chatStreamingStates.has(convId)) continue;

			const ts = this.conversationStateTimestamps.get(convId);

			cleanupCandidates.push({ convId, lastAccessed: ts?.lastAccessed ?? 0 });
		}
		cleanupCandidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
		let cleanedUp = 0;

		for (const { convId, lastAccessed } of cleanupCandidates) {
			if (
				cleanupCandidates.length - cleanedUp > INACTIVE_CONVERSATION.MAX_STATES ||
				now - lastAccessed > INACTIVE_CONVERSATION.MAX_AGE_MS
			) {
				this.cleanupConversationState(convId);
				cleanedUp++;
			}
		}

		return cleanedUp;
	}
	private cleanupConversationState(convId: string): void {
		const c = this.abortControllers.get(convId);

		if (c && !c.signal.aborted) c.abort();

		this.chatLoadingStates.delete(convId);
		this.chatStreamingStates.delete(convId);
		this.abortControllers.delete(convId);
		this.processingStates.delete(convId);
		this.conversationStateTimestamps.delete(convId);
	}
	getTrackedConversationCount(): number {
		return new Set([
			...this.chatLoadingStates.keys(),
			...this.chatStreamingStates.keys(),
			...this.abortControllers.keys(),
			...this.processingStates.keys()
		]).size;
	}

	private getMessageByIdWithRole(
		messageId: string,
		expectedRole?: MessageRole
	): { message: DatabaseMessage; index: number } | null {
		const index = conversationsStore.findMessageIndex(messageId);

		if (index === -1) return null;

		const message = conversationsStore.activeMessages[index];

		if (expectedRole && message.role !== expectedRole) return null;

		return { index, message };
	}

	async addMessage(
		role: MessageRole,
		content: string,
		type: MessageType = MessageType.TEXT,
		parent: string = '-1',
		extras?: DatabaseMessageExtra[],
		isSynthetic?: boolean
	): Promise<DatabaseMessage> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) throw new Error('No active conversation');

		let parentId: string | null = null;

		if (parent === '-1') {
			const am = conversationsStore.activeMessages;

			if (am.length > 0) parentId = am[am.length - 1].id;
			else {
				const all = await conversationsStore.getConversationMessages(activeConv.id);
				const r = all.find((m) => m.parent === null && m.type === 'root');

				parentId = r ? r.id : await DatabaseService.createRootMessage(activeConv.id);
			}
		} else parentId = parent;

		const message = await DatabaseService.createMessageBranch(
			{
				children: [],
				content,
				convId: activeConv.id,
				extra: extras,
				isSynthetic,
				role,
				timestamp: Date.now(),
				toolCalls: '',
				type
			},
			parentId
		);

		conversationsStore.addMessageToActive(message);
		await conversationsStore.updateCurrentNode(message.id);
		conversationsStore.updateConversationTimestamp();

		return message;
	}

	/**
	 * Record a working-directory change into chat history as a synthetic
	 * user message, so the model sees it on its next turn (the client
	 * sends the cwd itself via the x-tool-cwd header on tool calls).
	 * A plain user message is used because some chat templates reject
	 * tool messages without a preceding tool call.
	 */
	async recordCwdChange(cwd: string | null): Promise<void> {
		const content = cwd
			? formatCwdMessage(cwd, await toolsStore.resolveServerHome())
			: CWD_CLEARED_TEXT;
		// Reuse the trailing cwd row when it is already the last message, so
		// repeated picks update it in place instead of stacking another row.
		const last = conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];

		if (last && last.role === MessageRole.USER && last.isSynthetic === true) {
			await DatabaseService.updateMessage(last.id, { content, isSynthetic: true });
			conversationsStore.updateMessageAtIndex(conversationsStore.activeMessages.length - 1, {
				content,
				isSynthetic: true
			});

			return;
		}

		await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
	}

	async addSystemPrompt(): Promise<void> {
		let activeConv = conversationsStore.activeConversation;

		if (!activeConv) {
			await conversationsStore.createConversation();
			activeConv = conversationsStore.activeConversation;
		}

		if (!activeConv) return;

		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
			const rootId = rootMessage
				? rootMessage.id
				: await DatabaseService.createRootMessage(activeConv.id);
			const existingSystemMessage = allMessages.find(
				(m) => m.role === MessageRole.SYSTEM && m.parent === rootId
			);

			if (existingSystemMessage) {
				this.pendingEditMessageId = existingSystemMessage.id;

				if (!conversationsStore.activeMessages.some((m) => m.id === existingSystemMessage.id))
					conversationsStore.activeMessages.unshift(existingSystemMessage);

				return;
			}

			const am = conversationsStore.activeMessages;
			const firstActiveMessage = am.find((m) => m.parent === rootId);
			const systemMessage = await DatabaseService.createSystemMessage(
				activeConv.id,
				SYSTEM_MESSAGE_PLACEHOLDER,
				rootId
			);

			if (firstActiveMessage) {
				await DatabaseService.updateMessage(firstActiveMessage.id, {
					parent: systemMessage.id
				});
				await DatabaseService.updateMessage(systemMessage.id, {
					children: [firstActiveMessage.id]
				});
				const updatedRootChildren = rootMessage
					? rootMessage.children.filter((id: string) => id !== firstActiveMessage.id)
					: [];

				await DatabaseService.updateMessage(rootId, {
					children: [
						...updatedRootChildren.filter((id: string) => id !== systemMessage.id),
						systemMessage.id
					]
				});
				const firstMsgIndex = conversationsStore.findMessageIndex(firstActiveMessage.id);

				if (firstMsgIndex !== -1)
					conversationsStore.updateMessageAtIndex(firstMsgIndex, {
						parent: systemMessage.id
					});
			}

			conversationsStore.activeMessages.unshift(systemMessage);
			this.pendingEditMessageId = systemMessage.id;
			conversationsStore.updateConversationTimestamp();
		} catch (error) {
			console.error('Failed to add system prompt:', error);
		}
	}

	async removeSystemPromptPlaceholder(messageId: string): Promise<boolean> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return false;

		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const systemMessage = findMessageById(allMessages, messageId);

			if (!systemMessage || systemMessage.role !== MessageRole.SYSTEM) return false;

			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);

			if (!rootMessage) return false;

			if (allMessages.length === 2 && systemMessage.children.length === 0) {
				await conversationsStore.deleteConversation(activeConv.id);

				return true;
			}

			for (const childId of systemMessage.children) {
				await DatabaseService.updateMessage(childId, { parent: rootMessage.id });
				const childIndex = conversationsStore.findMessageIndex(childId);

				if (childIndex !== -1)
					conversationsStore.updateMessageAtIndex(childIndex, { parent: rootMessage.id });
			}
			await DatabaseService.updateMessage(rootMessage.id, {
				children: [
					...rootMessage.children.filter((id: string) => id !== messageId),
					...systemMessage.children
				]
			});
			await DatabaseService.deleteMessage(messageId);
			const systemIndex = conversationsStore.findMessageIndex(messageId);

			if (systemIndex !== -1) conversationsStore.activeMessages.splice(systemIndex, 1);

			conversationsStore.updateConversationTimestamp();

			return false;
		} catch (error) {
			console.error('Failed to remove system prompt placeholder:', error);

			return false;
		}
	}

	private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) throw new Error('No active conversation');

		return await DatabaseService.createMessageBranch(
			{
				children: [],
				content: '',
				convId: activeConv.id,
				model: null,
				role: MessageRole.ASSISTANT,
				timestamp: Date.now(),
				toolCalls: '',
				type: MessageType.TEXT
			},
			parentId || null
		);
	}

	async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
		if (!content.trim() && (!extras || extras.length === 0)) return;

		const activeConv = conversationsStore.activeConversation;

		// If agentic loop is running, inject as a steering message instead of starting a new flow
		if (activeConv && agenticStore.isRunning(activeConv.id)) {
			agenticStore.injectSteeringMessage(activeConv.id, content, extras);

			return;
		}

		// If non-agentic streaming is active, queue as a pending message to send after completion
		if (activeConv && this.isChatLoadingInternal(activeConv.id)) {
			this.injectPendingMessage(activeConv.id, content, extras);

			return;
		}

		// Cancel any in-flight pre-encode request
		this.cancelPreEncode();

		// Consume MCP resource attachments - converts them to extras and clears the live store
		const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras();
		const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras;

		let isNewConversation = false;

		if (!activeConv) {
			await conversationsStore.createConversation();
			isNewConversation = true;
		}

		const currentConv = conversationsStore.activeConversation;

		if (!currentConv) return;

		this.showErrorDialog(null);
		this.setChatLoading(currentConv.id, true);
		this.clearChatStreaming(currentConv.id);
		try {
			let parentIdForUserMessage: string | undefined;

			if (isNewConversation) {
				const rootId = await DatabaseService.createRootMessage(currentConv.id);
				const currentConfig = settingsStore.config;
				const systemPrompt = currentConfig.systemMessage?.toString().trim();

				let sysOrRootId = rootId;

				if (systemPrompt) {
					const systemMessage = await DatabaseService.createSystemMessage(
						currentConv.id,
						systemPrompt,
						rootId
					);

					conversationsStore.addMessageToActive(systemMessage);
					sysOrRootId = systemMessage.id;
				}

				// Reflect a working directory picked on the new-chat screen into
				// chat history before the first user message, so the model sees
				// it on its first turn. createConversation() has already threaded
				// the pending pick onto the conversation.
				if (currentConv.cwd) {
					const cwdMessage = await this.addMessage(
						MessageRole.USER,
						formatCwdMessage(currentConv.cwd, await toolsStore.resolveServerHome()),
						MessageType.TEXT,
						sysOrRootId,
						undefined,
						true
					);

					parentIdForUserMessage = cwdMessage.id;
				} else {
					parentIdForUserMessage = sysOrRootId;
				}
			}

			const userMessage = await this.addMessage(
				MessageRole.USER,
				content,
				MessageType.TEXT,
				parentIdForUserMessage ?? '-1',
				allExtras
			);

			if (isNewConversation && content)
				await conversationsStore.updateConversationName(
					currentConv.id,
					generateConversationTitle(
						content,
						Boolean(settingsStore.config.titleGenerationUseFirstLine)
					)
				);

			const assistantMessage = await this.createAssistantMessage(userMessage.id);

			conversationsStore.addMessageToActive(assistantMessage);
			await this.streamChatCompletion(
				conversationsStore.activeMessages.slice(0, -1),
				assistantMessage,
				undefined,
				undefined,
				undefined,
				settingsStore.config.titleGenerationUseLLM && isNewConversation ? content : undefined
			);
		} catch (error) {
			if (isAbortError(error)) {
				this.setChatLoading(currentConv.id, false);

				return;
			}

			console.error('Failed to send message:', error);
			this.setChatLoading(currentConv.id, false);
			const dialogType =
				error instanceof Error && error.name === 'TimeoutError'
					? ErrorDialogType.TIMEOUT
					: ErrorDialogType.SERVER;
			const contextInfo = (
				error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
			).contextInfo;

			this.showErrorDialog({
				contextInfo,
				message: error instanceof Error ? error.message : 'Unknown error',
				type: dialogType
			});
		}
	}

	private async streamChatCompletion(
		allMessages: DatabaseMessage[],
		assistantMessage: DatabaseMessage,
		onComplete?: (content: string) => Promise<void>,
		onError?: (error: Error) => void,
		modelOverride?: string | null,
		firstUserMessageContent?: string
	): Promise<void> {
		// the ::model suffix in the stream identity is only for router mode, where it routes to the
		// owning child. in single-model mode the identity stays the bare conv id so that attach, stop
		// and reattach all agree, regardless of fresh send vs regenerate passing a resolved model
		let effectiveModel: string | null | undefined = undefined;

		if (serverStore.isRouterMode) {
			const conversationModel = this.getConversationModel(allMessages);

			effectiveModel = modelOverride || modelsStore.selectedModelName || conversationModel;
		}

		if (serverStore.isRouterMode && effectiveModel) {
			if (!modelsStore.getModelProps(effectiveModel))
				await modelsStore.fetchModelProps(effectiveModel);
		}

		// Mutable state for the current message being streamed
		let currentMessageId = assistantMessage.id;
		let streamedContent = '';
		let streamedReasoningContent = '';
		let resolvedModel: string | null = null;
		let modelPersisted = false;

		const convId = assistantMessage.convId;

		// Tracks the last message created in this flow. Used as the parent for the next
		// turn's assistant message so createAssistantMessage does not have to read
		// conversationsStore.activeMessages, which may belong to a different conversation
		// after the user navigates while the loop is still running.
		let lastCreatedInFlow = currentMessageId;

		// freeze the POST identity from t0 so a stop cancels with the exact session key,
		// never a stale or empty model resolved later
		this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);

		const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
			if (!modelName) return;

			const n = normalizeModelName(modelName);

			if (!n || n === resolvedModel) return;

			resolvedModel = n;
			const idx = conversationsStore.findMessageIndex(currentMessageId);

			conversationsStore.updateMessageAtIndex(idx, { model: n });

			if (persistImmediately && !modelPersisted) {
				modelPersisted = true;
				DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => {
					modelPersisted = false;
					resolvedModel = null;
				});
			}
		};

		let completionIdRecorded = false;

		const recordCompletionId = (id: string): void => {
			if (!id || completionIdRecorded) return;

			completionIdRecorded = true;
			const idx = conversationsStore.findMessageIndex(currentMessageId);

			conversationsStore.updateMessageAtIndex(idx, { completionId: id });
			DatabaseService.updateMessage(currentMessageId, { completionId: id }).catch(() => {
				completionIdRecorded = false;
			});
		};
		const updateStreamingUI = () => {
			this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
			const idx = conversationsStore.findMessageIndex(currentMessageId);

			conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
		};
		const cleanupStreamingState = () => {
			this.setStreamingActive(false);
			this.setChatLoading(convId, false);
			this.clearChatStreaming(convId, currentMessageId);
			this.setProcessingState(convId, null);
		};

		this.setStreamingActive(true);
		this.setActiveProcessingConversation(convId);
		const abortController = this.getOrCreateAbortController(convId);
		const streamCallbacks: ChatStreamCallbacks = {
			createAssistantMessage: async () => {
				// Reset streaming state for new message
				streamedContent = '';
				streamedReasoningContent = '';

				const msg = await DatabaseService.createMessageBranch(
					{
						children: [],
						content: '',
						convId,
						model: resolvedModel,
						role: MessageRole.ASSISTANT,
						timestamp: Date.now(),
						toolCalls: '',
						type: MessageType.TEXT
					},
					lastCreatedInFlow
				);

				if (conversationsStore.activeConversation?.id === convId) {
					conversationsStore.addMessageToActive(msg);
				}

				currentMessageId = msg.id;
				lastCreatedInFlow = msg.id;

				return msg;
			},
			createToolResultMessage: async (
				toolCallId: string,
				content: string,
				extras?: DatabaseMessageExtra[],
				toolCwd?: string
			) => {
				const msg = await DatabaseService.createMessageBranch(
					{
						children: [],
						content,
						convId,
						extra: extras,
						role: MessageRole.TOOL,
						timestamp: Date.now(),
						toolCallId,
						toolCalls: '',
						toolCwd,
						type: MessageType.TEXT
					},
					currentMessageId
				);

				// mirror into the active store and move the node pointer only when this
				// conversation is displayed; otherwise persist the node move straight to
				// the db for the owning conv so a foreign conv's currNode stays untouched
				if (conversationsStore.activeConversation?.id === convId) {
					conversationsStore.addMessageToActive(msg);
					await conversationsStore.updateCurrentNode(msg.id);
				} else {
					await DatabaseService.updateCurrentNode(convId, msg.id);
				}

				lastCreatedInFlow = msg.id;

				return msg;
			},
			onAssistantTurnComplete: async (
				content: string,
				reasoningContent: string | undefined,
				timings: ChatMessageTimings | undefined,
				toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
			) => {
				const updateData: Record<string, unknown> = {
					content,
					reasoningContent: reasoningContent || undefined,
					timings,
					toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
				};

				if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;

				await DatabaseService.updateMessage(currentMessageId, updateData);
				const idx = conversationsStore.findMessageIndex(currentMessageId);
				const uiUpdate: Partial<DatabaseMessage> = {
					content,
					reasoningContent: reasoningContent || undefined,
					toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
				};

				if (timings) uiUpdate.timings = timings;

				if (resolvedModel) uiUpdate.model = resolvedModel;

				// touch the active ui array and node pointer only when this conversation
				// is displayed; otherwise persist the node move straight to the db so a
				// foreign conv's currNode stays untouched
				if (conversationsStore.activeConversation?.id === convId) {
					conversationsStore.updateMessageAtIndex(idx, uiUpdate);
					await conversationsStore.updateCurrentNode(currentMessageId);
				} else {
					await DatabaseService.updateCurrentNode(convId, currentMessageId);
				}
			},
			onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => {
				if (!extras.length) return;

				const idx = conversationsStore.findMessageIndex(messageId);

				if (idx === -1) return;

				const msg = conversationsStore.activeMessages[idx];
				const updatedExtras = [...(msg.extra || []), ...extras];

				conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
				DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
			},
			onChunk: (chunk: string) => {
				streamedContent += chunk;
				updateStreamingUI();
				this.setChatReasoning(convId, false);
			},
			onCompletionId: (id: string) => recordCompletionId(id),
			onError: async (error: Error) => {
				this.setStreamingActive(false);

				if (isAbortError(error)) {
					cleanupStreamingState();
					// If aborted with a pending message (e.g. "Send immediately"), re-send it
					const pending = this.consumePendingMessage(convId);

					if (pending) {
						this.sendMessage(pending.content, pending.extras);
					}

					return;
				}

				console.error('Streaming error:', error);
				// keep whatever was streamed so far, the message stays in memory and in DB
				await this.savePartialResponseIfNeeded(convId);
				cleanupStreamingState();
				this.clearPendingMessage(convId);

				const contextInfo = (
					error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
				).contextInfo;

				this.showErrorDialog({
					contextInfo,
					message: error.message,
					type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER
				});

				if (onError) onError(error);
			},
			onFlowComplete: (finalTimings?: ChatMessageTimings) => {
				if (finalTimings) {
					const idx = conversationsStore.findMessageIndex(assistantMessage.id);

					conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings });
					DatabaseService.updateMessage(assistantMessage.id, {
						timings: finalTimings
					}).catch(console.error);
				}

				cleanupStreamingState();

				if (onComplete) onComplete(streamedContent);

				if (serverStore.isRouterMode) modelsStore.fetchRouterModels().catch(console.error);

				// Pre-encode conversation in KV cache for faster next turn
				if (settingsStore.config.preEncodeConversation) {
					this.triggerPreEncode(
						allMessages,
						assistantMessage,
						streamedContent,
						effectiveModel,
						!!settingsStore.config.excludeReasoningFromContext
					);
				}
			},
			onModel: (modelName: string) => recordModel(modelName),
			onReasoningChunk: (chunk: string) => {
				streamedReasoningContent += chunk;
				// mark streaming state so a stop mid-thinking can persist the partial reasoning
				this.setChatStreaming(convId, streamedContent, currentMessageId, effectiveModel);
				const idx = conversationsStore.findMessageIndex(currentMessageId);

				conversationsStore.updateMessageAtIndex(idx, {
					reasoningContent: streamedReasoningContent
				});
				this.setChatReasoning(convId, true);
			},
			onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
				const tokensPerSecond =
					timings?.predicted_ms && timings?.predicted_n
						? (timings.predicted_n / timings.predicted_ms) * 1000
						: 0;

				this.updateProcessingStateFromTimings(
					{
						cache_n: timings?.cache_n || 0,
						predicted_n: timings?.predicted_n || 0,
						predicted_per_second: tokensPerSecond,
						prompt_ms: timings?.prompt_ms,
						prompt_n: timings?.prompt_n || 0,
						prompt_progress: promptProgress
					},
					convId
				);
			},
			onToolCallsStreaming: (toolCalls) => {
				const idx = conversationsStore.findMessageIndex(currentMessageId);

				conversationsStore.updateMessageAtIndex(idx, {
					toolCalls: JSON.stringify(toolCalls)
				});
			},
			onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
				// Update the first assistant message with cumulative agentic timings
				const idx = conversationsStore.findMessageIndex(assistantMessage.id);

				conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
			},
			updateToolResultMessage: async (
				messageId: string,
				content: string,
				extras?: DatabaseMessageExtra[]
			) => {
				// Persist latest content + merged extras; mirror into the active
				// store so the chat view sees live updates for streaming tools
				// (e.g. exec_shell_command). The existing tool message node
				// pointer stays put - the renderer is already scoped to it.
				const updates: Partial<DatabaseMessage> = { content };

				if (extras) {
					const idx = conversationsStore.findMessageIndex(messageId);
					const existing = idx >= 0 ? (conversationsStore.activeMessages[idx]?.extra ?? []) : [];
					const merged = [...existing, ...extras];

					updates.extra = merged;
				}

				if (conversationsStore.activeConversation?.id === convId) {
					const idx = conversationsStore.findMessageIndex(messageId);

					if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
				}

				await DatabaseService.updateMessage(messageId, updates);
			}
		};
		const perChatOverrides = conversationsStore.getAllMcpServerOverrides();

		{
			const agenticResult = await agenticStore.runAgenticFlow({
				callbacks: streamCallbacks,
				conversationId: convId,
				flowRootMessageId: assistantMessage.id,
				messages: allMessages,
				options: {
					...this.getApiOptions(),
					...(effectiveModel ? { model: effectiveModel } : {})
				},
				perChatOverrides,
				signal: abortController.signal
			});

			if (agenticResult.handled) {
				// Generate LLM based title for new conversations after agentic flow completes
				if (firstUserMessageContent) {
					await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
				}

				// Check if there's a pending steering message to re-send
				const pending = agenticStore.consumePendingSteeringMessage(convId);

				if (pending) {
					await this.sendMessage(pending.content, pending.extras);
				}

				return;
			}
		}

		await ChatService.sendMessage(
			allMessages,
			{
				...this.getApiOptions(),
				...(effectiveModel ? { model: effectiveModel } : {}),
				onChunk: streamCallbacks.onChunk,
				onComplete: async (
					finalContent?: string,
					reasoningContent?: string,
					timings?: ChatMessageTimings,
					toolCalls?: string
				) => {
					const content = streamedContent || finalContent || '';
					const reasoning = streamedReasoningContent || reasoningContent;
					const updateData: Record<string, unknown> = {
						content,
						reasoningContent: reasoning || undefined,
						timings,
						toolCalls: toolCalls || ''
					};

					if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;

					await DatabaseService.updateMessage(currentMessageId, updateData);
					const idx = conversationsStore.findMessageIndex(currentMessageId);
					const uiUpdate: Partial<DatabaseMessage> = {
						content,
						reasoningContent: reasoning || undefined,
						toolCalls: toolCalls || ''
					};

					if (timings) uiUpdate.timings = timings;

					if (resolvedModel) uiUpdate.model = resolvedModel;

					conversationsStore.updateMessageAtIndex(idx, uiUpdate);
					await conversationsStore.updateCurrentNode(currentMessageId);
					cleanupStreamingState();

					if (onComplete) await onComplete(content);

					if (serverStore.isRouterMode) modelsStore.fetchRouterModels().catch(console.error);

					// Generate LLM based title for new conversations (avoids stale reference
					// issue when user switches conversations while streaming)
					if (firstUserMessageContent) {
						await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
					}

					// Check if there's a pending message queued during streaming
					const pending = this.consumePendingMessage(convId);

					if (pending) {
						await this.sendMessage(pending.content, pending.extras);
					}
				},
				onCompletionId: streamCallbacks.onCompletionId,
				onConnectionState: (state: StreamConnectionState) => {
					if (convId === conversationsStore.activeConversation?.id) {
						this.streamConnectionState = state;
					}
				},
				onError: streamCallbacks.onError,
				onModel: streamCallbacks.onModel,
				onReasoningChunk: streamCallbacks.onReasoningChunk,
				onTimings: streamCallbacks.onTimings,
				stream: true
			},
			convId,
			abortController.signal
		);
	}

	async stopGeneration(): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return;

		await this.stopGenerationForChat(activeConv.id);
	}
	async stopGenerationForChat(convId: string): Promise<void> {
		await this.savePartialResponseIfNeeded(convId);
		this.setStreamingActive(false);
		// tell the server to stop the generation, not just drop the HTTP socket. without this the
		// detached drain keeps producing tokens until eos or max_tokens. use the frozen identity
		// captured when the session started, not the live dropdown
		const streamStateForStop = this.chatStreamingStates.get(convId);
		const modelForStop = streamStateForStop?.model ?? ChatService.getStreamState(convId)?.model;

		void ChatService.cancelServerStream(convId, modelForStop);
		// an explicit stop leaves nothing to resume and kills a pending resume retry
		ChatService.clearStreamState(convId);
		const retryTimer = this.resumeRetryTimers.get(convId);

		if (retryTimer !== undefined) {
			clearTimeout(retryTimer);
			this.resumeRetryTimers.delete(convId);
		}

		this.resumePendingConvs.delete(convId);
		this.abortRequest(convId);
		this.setChatLoading(convId, false);
		this.clearChatStreaming(convId);
		this.setProcessingState(convId, null);
		this.clearPendingMessage(convId);
	}

	private async generateTitleWithLLM(
		userContent: string,
		assistantContent: string,
		convId: string
	): Promise<void> {
		const effectiveModel =
			serverStore.isRouterMode && modelsStore.selectedModelName
				? modelsStore.selectedModelName
				: undefined;
		const configValue = settingsStore.config;
		const titlePromptTemplate =
			typeof configValue.titleGenerationPrompt === 'string' &&
			configValue.titleGenerationPrompt.trim()
				? configValue.titleGenerationPrompt
				: TITLE_GENERATION.DEFAULT_PROMPT;
		const titlePrompt = titlePromptTemplate
			.replace('{{USER}}', String(userContent || ''))
			.replace('{{ASSISTANT}}', String(assistantContent || ''));
		const titleMessage: ApiChatMessageData = {
			content: titlePrompt,
			role: MessageRole.USER
		};
		const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel);

		if (!titleResponse) {
			return;
		}

		let cleanTitle = titleResponse.trim();

		cleanTitle = cleanTitle
			.replace(TITLE_GENERATION.PREFIX_PATTERN, '')
			.replace(TITLE_GENERATION.QUOTE_PATTERN, '')
			.trim();

		if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) {
			const firstLine = userContent.split('\n').find((l) => l.trim().length > 0);

			cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK;
		}

		if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) {
			await conversationsStore.updateConversationName(convId, cleanTitle);
		}
	}

	private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
		const conversationId = convId || conversationsStore.activeConversation?.id;

		if (!conversationId) return;

		const streamingState = this.getChatStreamingState(conversationId);

		if (!streamingState) return;

		const messages =
			conversationId === conversationsStore.activeConversation?.id
				? conversationsStore.activeMessages
				: await conversationsStore.getConversationMessages(conversationId);

		if (!messages.length) return;

		const lastMessage = messages[messages.length - 1];

		if (lastMessage?.role !== MessageRole.ASSISTANT) return;

		const partialContent = streamingState.response;
		const partialReasoning = lastMessage.reasoningContent || '';
		// snapshot the streamed tool calls before clearing so we still know whether
		// anything was captured when deciding to skip the DB write below
		const hadPartialToolCalls = !!lastMessage.toolCalls?.trim();

		// nothing to persist when content, reasoning, and streamed tool calls are all empty
		// (e.g. stop before any token). otherwise drop the partial tool call and write whatever
		// was streamed: incomplete arguments (truncated JSON, missing closing quote) would
		// otherwise be re-sent to the server on the next turn and rejected.
		if (!partialContent.trim() && !partialReasoning.trim() && !hadPartialToolCalls) return;

		try {
			const updateData: {
				content?: string;
				reasoningContent?: string;
				toolCalls?: string;
				timings?: ChatMessageTimings;
			} = {
				toolCalls: ''
			};

			if (partialContent.trim()) updateData.content = partialContent;

			if (partialReasoning.trim()) updateData.reasoningContent = partialReasoning;

			const lastKnownState = this.getProcessingState(conversationId);

			if (lastKnownState) {
				updateData.timings = {
					cache_n: lastKnownState.cacheTokens || 0,
					predicted_ms:
						lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
							? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
							: undefined,
					predicted_n: lastKnownState.tokensDecoded || 0,
					prompt_ms: lastKnownState.promptMs,
					prompt_n: lastKnownState.promptTokens || 0
				};
			}

			await DatabaseService.updateMessage(lastMessage.id, updateData);
			lastMessage.content = partialContent;
			// mirror the drop into the in-memory message so the next request sent via
			// sendMessage (queued pending, Send immediately, or manual follow-up) reads
			// the cleared value, not whatever the streaming widget had been showing
			lastMessage.toolCalls = '';

			if (updateData.timings) lastMessage.timings = updateData.timings;
		} catch (error) {
			lastMessage.content = partialContent;
			lastMessage.toolCalls = '';
			console.error('Failed to save partial response:', error);
		}
	}

	async updateMessage(messageId: string, newContent: string): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return;

		if (this.isChatLoadingInternal(activeConv.id)) await this.stopGeneration();

		const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);

		if (!result) return;

		const { index: messageIndex, message: messageToUpdate } = result;
		const originalContent = messageToUpdate.content;

		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
			const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;

			conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
			await DatabaseService.updateMessage(messageId, { content: newContent });

			if (isFirstUserMessage && newContent.trim())
				await conversationsStore.updateConversationName(
					activeConv.id,
					generateConversationTitle(
						newContent,
						Boolean(settingsStore.config.titleGenerationUseFirstLine)
					)
				);

			const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);

			if (messagesToRemove.length > 0)
				await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);

			conversationsStore.sliceActiveMessages(messageIndex + 1);
			conversationsStore.updateConversationTimestamp();
			this.setChatLoading(activeConv.id, true);
			this.clearChatStreaming(activeConv.id);
			const assistantMessage = await this.createAssistantMessage();

			conversationsStore.addMessageToActive(assistantMessage);
			await conversationsStore.updateCurrentNode(assistantMessage.id);
			await this.streamChatCompletion(
				conversationsStore.activeMessages.slice(0, -1),
				assistantMessage,
				undefined,
				() => {
					conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), {
						content: originalContent
					});
				}
			);
		} catch (error) {
			if (!isAbortError(error)) console.error('Failed to update message:', error);
		}
	}

	async regenerateMessage(messageId: string): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;

		this.cancelPreEncode();
		const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);

		if (!result) return;

		const { index: messageIndex } = result;

		try {
			const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);

			await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
			conversationsStore.sliceActiveMessages(messageIndex);
			conversationsStore.updateConversationTimestamp();
			this.setChatLoading(activeConv.id, true);
			this.clearChatStreaming(activeConv.id);
			const parentMessageId =
				conversationsStore.activeMessages.length > 0
					? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
					: undefined;
			const assistantMessage = await this.createAssistantMessage(parentMessageId);

			conversationsStore.addMessageToActive(assistantMessage);
			await this.streamChatCompletion(
				conversationsStore.activeMessages.slice(0, -1),
				assistantMessage
			);
		} catch (error) {
			if (!isAbortError(error)) console.error('Failed to regenerate message:', error);

			this.setChatLoading(activeConv?.id || '', false);
		}
	}

	async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;

		this.cancelPreEncode();
		try {
			const idx = conversationsStore.findMessageIndex(messageId);

			if (idx === -1) return;

			const msg = conversationsStore.activeMessages[idx];

			if (msg.role !== MessageRole.ASSISTANT) return;

			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const parentMessage = findMessageById(allMessages, msg.parent);

			if (!parentMessage) return;

			this.setChatLoading(activeConv.id, true);
			this.clearChatStreaming(activeConv.id);
			const newAssistantMessage = await DatabaseService.createMessageBranch(
				{
					children: [],
					content: '',
					convId: msg.convId,
					model: null,
					role: msg.role,
					timestamp: Date.now(),
					toolCalls: '',
					type: msg.type
				},
				parentMessage.id
			);

			await conversationsStore.updateCurrentNode(newAssistantMessage.id);
			conversationsStore.updateConversationTimestamp();
			await conversationsStore.refreshActiveMessages();
			const conversationPath = filterByLeafNodeId(
				allMessages,
				parentMessage.id,
				false
			) as DatabaseMessage[];
			const modelToUse = modelOverride || msg.model || undefined;

			await this.streamChatCompletion(
				conversationPath,
				newAssistantMessage,
				undefined,
				undefined,
				modelToUse
			);
		} catch (error) {
			if (!isAbortError(error))
				console.error('Failed to regenerate message with branching:', error);

			this.setChatLoading(activeConv?.id || '', false);
		}
	}

	async getDeletionInfo(messageId: string): Promise<{
		totalCount: number;
		userMessages: number;
		assistantMessages: number;
		messageTypes: string[];
	}> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv)
			return { assistantMessages: 0, messageTypes: [], totalCount: 0, userMessages: 0 };

		const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
		const messageToDelete = findMessageById(allMessages, messageId);

		// For system messages, don't count descendants as they will be preserved (reparented to root)
		if (messageToDelete?.role === MessageRole.SYSTEM) {
			const messagesToDelete = allMessages.filter((m) => m.id === messageId);

			let assistantMessages = 0,
				userMessages = 0;

			const messageTypes: string[] = [];

			for (const msg of messagesToDelete) {
				if (msg.role === MessageRole.USER) {
					userMessages++;

					if (!messageTypes.includes('user message')) messageTypes.push('user message');
				} else if (msg.role === MessageRole.ASSISTANT) {
					assistantMessages++;

					if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
				}
			}

			return { assistantMessages, messageTypes, totalCount: 1, userMessages };
		}

		const descendants = findDescendantMessages(allMessages, messageId);
		const allToDelete = [messageId, ...descendants];
		const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));

		let assistantMessages = 0,
			userMessages = 0;

		const messageTypes: string[] = [];

		for (const msg of messagesToDelete) {
			if (msg.role === MessageRole.USER) {
				userMessages++;

				if (!messageTypes.includes('user message')) messageTypes.push('user message');
			} else if (msg.role === MessageRole.ASSISTANT) {
				assistantMessages++;

				if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response');
			}
		}

		return { assistantMessages, messageTypes, totalCount: allToDelete.length, userMessages };
	}

	async deleteMessage(messageId: string): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return;

		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const messageToDelete = findMessageById(allMessages, messageId);

			if (!messageToDelete) return;

			const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
			const isInCurrentPath = currentPath.some((m) => m.id === messageId);

			if (isInCurrentPath && messageToDelete.parent) {
				const siblings = allMessages.filter(
					(m) => m.parent === messageToDelete.parent && m.id !== messageId
				);

				if (siblings.length > 0) {
					const latestSibling = siblings.reduce((latest, sibling) =>
						sibling.timestamp > latest.timestamp ? sibling : latest
					);

					await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
				} else if (messageToDelete.parent) {
					await conversationsStore.updateCurrentNode(
						findLeafNode(allMessages, messageToDelete.parent)
					);
				}
			}

			await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
			await conversationsStore.refreshActiveMessages();

			conversationsStore.updateConversationTimestamp();
		} catch (error) {
			console.error('Failed to delete message:', error);
		}
	}

	/**
	 * Open a fresh assistant turn anchored at the last tool result of a resolved
	 * agentic round and let streamChatCompletion route through runAgenticFlow.
	 * Used by continueAssistantMessage when classifyContinueIntent returns
	 * next_turn, meaning the target assistant already has its tool_calls paired
	 * with trailing tool results and the next thing to generate is a brand new
	 * turn rather than a token level continuation.
	 */
	private async continueAsNextAgenticTurn(anchorIndex: number): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return;

		const anchor = conversationsStore.activeMessages[anchorIndex];

		if (!anchor) return;

		this.cancelPreEncode();
		this.setChatLoading(activeConv.id, true);
		this.clearChatStreaming(activeConv.id);
		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const anchorMessage = findMessageById(allMessages, anchor.id);

			if (!anchorMessage) {
				this.setChatLoading(activeConv.id, false);

				return;
			}

			const newAssistantMessage = await DatabaseService.createMessageBranch(
				{
					children: [],
					content: '',
					convId: activeConv.id,
					model: null,
					role: MessageRole.ASSISTANT,
					timestamp: Date.now(),
					toolCalls: '',
					type: MessageType.TEXT
				},
				anchorMessage.id
			);

			await conversationsStore.updateCurrentNode(newAssistantMessage.id);
			conversationsStore.updateConversationTimestamp();
			await conversationsStore.refreshActiveMessages();
			const conversationPath = filterByLeafNodeId(
				allMessages,
				anchorMessage.id,
				false
			) as DatabaseMessage[];

			await this.streamChatCompletion(conversationPath, newAssistantMessage);
		} catch (error) {
			if (!isAbortError(error)) console.error('Failed to continue agentic turn:', error);

			this.setChatLoading(activeConv.id, false);
		}
	}

	async continueAssistantMessage(messageId: string): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;

		const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);

		if (!result) return;

		const { index: idx, message: msg } = result;
		// Decide which resume path applies. tool_calls without tool results can
		// not be resumed mid sequence by continue_final_message, branch instead.
		// tool_calls already paired with tool results need a fresh next turn,
		// not a token level continuation of the target assistant.
		const intent = classifyContinueIntent(conversationsStore.activeMessages, idx);

		if (intent.kind === ContinueIntentKind.RERUN_TURN) {
			return this.regenerateMessageWithBranching(messageId);
		}

		if (intent.kind === ContinueIntentKind.NEXT_TURN) {
			return this.continueAsNextAgenticTurn(intent.truncateAfter);
		}

		try {
			this.showErrorDialog(null);
			this.setChatLoading(activeConv.id, true);
			this.clearChatStreaming(activeConv.id);

			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const dbMessage = findMessageById(allMessages, messageId);

			if (!dbMessage) {
				this.setChatLoading(activeConv.id, false);

				return;
			}

			const originalContent = dbMessage.content;
			const originalReasoning = dbMessage.reasoningContent || '';
			// Hand the persisted DatabaseMessage straight to sendMessage so its
			// internal converter preserves tool_calls and extras when present.
			// Reconstructing a bare {role, content} here would drop those fields
			// and break continue_final_message for messages with tool calls.
			const contextWithContinue = conversationsStore.activeMessages.slice(0, idx + 1);

			let appendedContent = '';
			let appendedReasoning = '';
			let hasReceivedContent = false;

			const updateStreamingContent = (fullContent: string) => {
				this.setChatStreaming(msg.convId, fullContent, msg.id);
				// resolve the row by id on every write, switching to another conv mid continue makes
				// this a no op instead of writing positionally into the now displayed conversation
				conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
					content: fullContent
				});
			};
			const abortController = this.getOrCreateAbortController(msg.convId);

			await ChatService.sendMessage(
				contextWithContinue,
				{
					...this.getApiOptions(),
					continueFinalMessage: true,
					onChunk: (chunk: string) => {
						appendedContent += chunk;
						hasReceivedContent = true;
						updateStreamingContent(originalContent + appendedContent);
						this.setChatReasoning(msg.convId, false);
					},
					onComplete: async (
						finalContent?: string,
						reasoningContent?: string,
						timings?: ChatMessageTimings
					) => {
						const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
						const finalAppendedReasoning = hasReceivedContent
							? appendedReasoning
							: reasoningContent || '';
						const fullContent = originalContent + finalAppendedContent;
						const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;

						await DatabaseService.updateMessage(msg.id, {
							content: fullContent,
							reasoningContent: fullReasoning,
							timestamp: Date.now(),
							timings
						});

						conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
							content: fullContent,
							reasoningContent: fullReasoning,
							timestamp: Date.now(),
							timings
						});

						conversationsStore.updateConversationTimestamp(msg.convId);

						this.setChatLoading(msg.convId, false);
						this.clearChatStreaming(msg.convId);
						this.setProcessingState(msg.convId, null);
					},
					onCompletionId: (id: string) => {
						if (!id) return;

						// refresh the message id so a later skip targets the live slot after a continue
						conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
							completionId: id
						});
						DatabaseService.updateMessage(msg.id, { completionId: id }).catch(() => {});
					},
					onConnectionState: (state: StreamConnectionState) => {
						if (msg.convId === conversationsStore.activeConversation?.id) {
							this.streamConnectionState = state;
						}
					},
					onError: async (error: Error) => {
						if (isAbortError(error)) {
							if (hasReceivedContent && appendedContent) {
								await DatabaseService.updateMessage(msg.id, {
									content: originalContent + appendedContent,
									reasoningContent: originalReasoning + appendedReasoning || undefined,
									timestamp: Date.now()
								});

								conversationsStore.updateMessageAtIndex(
									conversationsStore.findMessageIndex(msg.id),
									{
										content: originalContent + appendedContent,
										reasoningContent: originalReasoning + appendedReasoning || undefined,
										timestamp: Date.now()
									}
								);
							}

							this.setChatLoading(msg.convId, false);
							this.clearChatStreaming(msg.convId);
							this.setProcessingState(msg.convId, null);

							return;
						}

						console.error('Continue generation error:', error);
						// keep whatever was appended so far, the message stays in memory and in DB
						await DatabaseService.updateMessage(msg.id, {
							content: originalContent + appendedContent,
							reasoningContent: originalReasoning + appendedReasoning || undefined,
							timestamp: Date.now()
						});
						conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
							content: originalContent + appendedContent,
							reasoningContent: originalReasoning + appendedReasoning || undefined,
							timestamp: Date.now()
						});

						this.setChatLoading(msg.convId, false);
						this.clearChatStreaming(msg.convId);
						this.setProcessingState(msg.convId, null);
						this.showErrorDialog({
							message: error.message,
							type: error.name === 'TimeoutError' ? ErrorDialogType.TIMEOUT : ErrorDialogType.SERVER
						});
					},
					onReasoningChunk: (chunk: string) => {
						appendedReasoning += chunk;
						hasReceivedContent = true;
						// mark streaming state so a stop mid-thinking can persist the partial reasoning
						this.setChatStreaming(msg.convId, originalContent + appendedContent, msg.id);
						conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(msg.id), {
							reasoningContent: originalReasoning + appendedReasoning
						});
						this.setChatReasoning(msg.convId, true);
					},
					onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
						const tokensPerSecond =
							timings?.predicted_ms && timings?.predicted_n
								? (timings.predicted_n / timings.predicted_ms) * 1000
								: 0;

						this.updateProcessingStateFromTimings(
							{
								cache_n: timings?.cache_n || 0,
								predicted_n: timings?.predicted_n || 0,
								predicted_per_second: tokensPerSecond,
								prompt_ms: timings?.prompt_ms,
								prompt_n: timings?.prompt_n || 0,
								prompt_progress: promptProgress
							},
							msg.convId
						);
					}
				},

				msg.convId,
				abortController.signal
			);
		} catch (error) {
			if (!isAbortError(error)) console.error('Failed to continue message:', error);

			if (activeConv) this.setChatLoading(activeConv.id, false);
		}
	}

	async editAssistantMessage(
		messageId: string,
		newContent: string,
		shouldBranch: boolean
	): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;

		const result = this.getMessageByIdWithRole(messageId, MessageRole.ASSISTANT);

		if (!result) return;

		const { index: idx, message: msg } = result;

		try {
			if (shouldBranch) {
				const newMessage = await DatabaseService.createMessageBranch(
					{
						children: [],
						content: newContent,
						convId: msg.convId,
						model: msg.model,
						role: msg.role,
						timestamp: Date.now(),
						toolCalls: msg.toolCalls || '',
						type: msg.type
					},
					msg.parent!
				);

				await conversationsStore.updateCurrentNode(newMessage.id);
			} else {
				await DatabaseService.updateMessage(msg.id, { content: newContent });
				conversationsStore.updateMessageAtIndex(idx, { content: newContent });
			}

			conversationsStore.updateConversationTimestamp();

			await conversationsStore.refreshActiveMessages();
		} catch (error) {
			console.error('Failed to edit assistant message:', error);
		}
	}

	async editUserMessagePreserveResponses(
		messageId: string,
		newContent: string,
		newExtras?: DatabaseMessageExtra[]
	): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return;

		const result = this.getMessageByIdWithRole(messageId, MessageRole.USER);

		if (!result) return;

		const { index: idx, message: msg } = result;

		try {
			const updateData: Partial<DatabaseMessage> = { content: newContent };

			if (newExtras !== undefined) updateData.extra = JSON.parse(JSON.stringify(newExtras));

			await DatabaseService.updateMessage(messageId, updateData);

			conversationsStore.updateMessageAtIndex(idx, updateData);

			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);

			if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
				await conversationsStore.updateConversationName(
					activeConv.id,
					generateConversationTitle(
						newContent,
						Boolean(settingsStore.config.titleGenerationUseFirstLine)
					)
				);
			}

			conversationsStore.updateConversationTimestamp();
		} catch (error) {
			console.error('Failed to edit user message:', error);
		}
	}

	async editMessageWithBranching(
		messageId: string,
		newContent: string,
		newExtras?: DatabaseMessageExtra[]
	): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv || this.isChatLoadingInternal(activeConv.id)) return;

		let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);

		if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);

		if (!result) return;

		const { index: idx, message: msg } = result;

		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
			const isFirstUserMessage =
				msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
			const extrasToUse =
				newExtras !== undefined
					? JSON.parse(JSON.stringify(newExtras))
					: msg.extra
						? JSON.parse(JSON.stringify(msg.extra))
						: undefined;

			let messageIdForResponse: string;

			const dbMsg = findMessageById(allMessages, msg.id);
			const hasChildren = dbMsg ? dbMsg.children.length > 0 : msg.children.length > 0;

			if (!hasChildren) {
				// No responses after this message — update in place instead of branching
				const updates: Partial<DatabaseMessage> = {
					content: newContent,
					extra: extrasToUse,
					timestamp: Date.now()
				};

				await DatabaseService.updateMessage(msg.id, updates);
				conversationsStore.updateMessageAtIndex(idx, updates);
				messageIdForResponse = msg.id;
			} else {
				// Has children — create a new branch as sibling
				const parentId = msg.parent || rootMessage?.id;

				if (!parentId) return;

				const newMessage = await DatabaseService.createMessageBranch(
					{
						children: [],
						content: newContent,
						convId: msg.convId,
						extra: extrasToUse,
						model: msg.model,
						role: msg.role,
						timestamp: Date.now(),
						toolCalls: msg.toolCalls || '',
						type: msg.type
					},
					parentId
				);

				await conversationsStore.updateCurrentNode(newMessage.id);
				messageIdForResponse = newMessage.id;
			}

			conversationsStore.updateConversationTimestamp();

			if (isFirstUserMessage && newContent.trim())
				await conversationsStore.updateConversationName(
					activeConv.id,
					generateConversationTitle(
						newContent,
						Boolean(settingsStore.config.titleGenerationUseFirstLine)
					)
				);

			await conversationsStore.refreshActiveMessages();

			if (msg.role === MessageRole.USER)
				await this.generateResponseForMessage(messageIdForResponse);
		} catch (error) {
			console.error('Failed to edit message with branching:', error);
		}
	}

	private async generateResponseForMessage(userMessageId: string): Promise<void> {
		const activeConv = conversationsStore.activeConversation;

		if (!activeConv) return;

		this.showErrorDialog(null);
		this.setChatLoading(activeConv.id, true);
		this.clearChatStreaming(activeConv.id);

		try {
			const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
			const conversationPath = filterByLeafNodeId(
				allMessages,
				userMessageId,
				false
			) as DatabaseMessage[];
			const assistantMessage = await DatabaseService.createMessageBranch(
				{
					children: [],
					content: '',
					convId: activeConv.id,
					model: null,
					role: MessageRole.ASSISTANT,
					timestamp: Date.now(),
					toolCalls: '',
					type: MessageType.TEXT
				},
				userMessageId
			);

			conversationsStore.addMessageToActive(assistantMessage);

			await this.streamChatCompletion(conversationPath, assistantMessage);
		} catch (error) {
			console.error('Failed to generate response:', error);
			this.setChatLoading(activeConv.id, false);
		}
	}

	private getContextTotal(): number | null {
		const activeConvId = this.activeConversationId;
		const activeState = activeConvId ? this.getProcessingState(activeConvId) : null;

		if (activeState && typeof activeState.contextTotal === 'number' && activeState.contextTotal > 0)
			return activeState.contextTotal;

		if (serverStore.isRouterMode) {
			const modelContextSize = modelsStore.selectedModelContextSize;

			if (typeof modelContextSize === 'number' && modelContextSize > 0) {
				return modelContextSize;
			}
		} else {
			const propsContextSize = serverStore.contextSize;

			if (typeof propsContextSize === 'number' && propsContextSize > 0) {
				return propsContextSize;
			}
		}

		return null;
	}

	updateProcessingStateFromTimings(
		timingData: {
			prompt_n: number;
			prompt_ms?: number;
			predicted_n: number;
			predicted_per_second: number;
			cache_n: number;
			prompt_progress?: ChatMessagePromptProgress;
		},
		conversationId?: string
	): void {
		const processingState = this.parseTimingData(timingData);

		if (processingState === null) {
			console.warn('Failed to parse timing data - skipping update');

			return;
		}

		const targetId = conversationId || this.activeConversationId;

		if (targetId) {
			this.setProcessingState(targetId, processingState);
		}
	}

	private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
		const cacheTokens = (timingData.cache_n as number) || 0,
			predictedTokens = (timingData.predicted_n as number) || 0,
			promptMs = (timingData.prompt_ms as number) || undefined,
			promptTokens = (timingData.prompt_n as number) || 0,
			tokensPerSecond = (timingData.predicted_per_second as number) || 0;
		const promptProgress = timingData.prompt_progress as
			| { total: number; cache: number; processed: number; time_ms: number }
			| undefined;
		const contextTotal = this.getContextTotal();
		const currentConfig = settingsStore.config;
		const outputTokensMax = currentConfig.max_tokens || -1;
		const contextUsed = promptTokens + cacheTokens + predictedTokens,
			outputTokensUsed = predictedTokens;
		const progressCache = promptProgress?.cache || 0,
			progressActualDone = (promptProgress?.processed ?? 0) - progressCache,
			progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
		const progressPercent = promptProgress
			? Math.round((progressActualDone / progressActualTotal) * 100)
			: undefined;

		return {
			cacheTokens,
			contextTotal,
			contextUsed,
			hasNextToken: predictedTokens > 0,
			outputTokensMax,
			outputTokensUsed,
			progressPercent,
			promptMs,
			promptProgress,
			promptTokens,
			speculative: false,
			status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
			temperature: currentConfig.temperature ?? 0.8,
			tokensDecoded: predictedTokens,
			tokensPerSecond,
			tokensRemaining: outputTokensMax - predictedTokens,
			topP: currentConfig.top_p ?? 0.95
		};
	}

	restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
		for (let i = messages.length - 1; i >= 0; i--) {
			const message = messages[i];

			if (message.role === MessageRole.ASSISTANT && message.timings) {
				const restoredState = this.parseTimingData({
					cache_n: message.timings.cache_n || 0,
					predicted_n: message.timings.predicted_n || 0,
					predicted_per_second:
						message.timings.predicted_n && message.timings.predicted_ms
							? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
							: 0,
					prompt_ms: message.timings.prompt_ms,
					prompt_n: message.timings.prompt_n || 0
				});

				if (restoredState) {
					this.setProcessingState(conversationId, restoredState);

					return;
				}
			}
		}
	}

	getConversationModel(messages: DatabaseMessage[]): string | null {
		for (let i = messages.length - 1; i >= 0; i--) {
			const message = messages[i];

			if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
		}

		return null;
	}

	private getApiOptions(): Record<string, unknown> {
		const currentConfig = settingsStore.config;
		const hasValue = (value: unknown): boolean =>
			value !== undefined && value !== null && value !== '';
		const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };

		if (serverStore.isRouterMode) {
			const modelName = modelsStore.selectedModelName;

			if (modelName) apiOptions.model = modelName;
		}

		if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage;

		if (currentConfig.disableReasoningParsing) apiOptions.disableReasoningParsing = true;

		if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;

		// an explicit reasoning choice overrides the server default, DEFAULT sends nothing
		const effort = conversationsStore.getReasoningEffort();

		if (effort !== ReasoningEffort.DEFAULT) {
			apiOptions.enableThinking = effort !== ReasoningEffort.OFF;

			if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort;
		}

		if (hasValue(currentConfig.temperature))
			apiOptions.temperature = Number(currentConfig.temperature);

		if (hasValue(currentConfig.max_tokens))
			apiOptions.max_tokens = Number(currentConfig.max_tokens);

		if (hasValue(currentConfig.dynatemp_range))
			apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range);

		if (hasValue(currentConfig.dynatemp_exponent))
			apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent);

		if (hasValue(currentConfig.top_k)) apiOptions.top_k = Number(currentConfig.top_k);

		if (hasValue(currentConfig.top_p)) apiOptions.top_p = Number(currentConfig.top_p);

		if (hasValue(currentConfig.min_p)) apiOptions.min_p = Number(currentConfig.min_p);

		if (hasValue(currentConfig.xtc_probability))
			apiOptions.xtc_probability = Number(currentConfig.xtc_probability);

		if (hasValue(currentConfig.xtc_threshold))
			apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold);

		if (hasValue(currentConfig.typ_p)) apiOptions.typ_p = Number(currentConfig.typ_p);

		if (hasValue(currentConfig.repeat_last_n))
			apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n);

		if (hasValue(currentConfig.repeat_penalty))
			apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty);

		if (hasValue(currentConfig.presence_penalty))
			apiOptions.presence_penalty = Number(currentConfig.presence_penalty);

		if (hasValue(currentConfig.frequency_penalty))
			apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty);

		if (hasValue(currentConfig.dry_multiplier))
			apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier);

		if (hasValue(currentConfig.dry_base)) apiOptions.dry_base = Number(currentConfig.dry_base);

		if (hasValue(currentConfig.dry_allowed_length))
			apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length);

		if (hasValue(currentConfig.dry_penalty_last_n))
			apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);

		if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;

		if (hasValue(currentConfig.backend_sampling))
			apiOptions.backend_sampling = currentConfig.backend_sampling;

		if (currentConfig.customJson) apiOptions.custom = currentConfig.customJson;

		return apiOptions;
	}

	private cancelPreEncode(): void {
		if (this.preEncodeAbortController) {
			this.preEncodeAbortController.abort();
			this.preEncodeAbortController = null;
		}
	}

	private async triggerPreEncode(
		allMessages: DatabaseMessage[],
		assistantMessage: DatabaseMessage,
		assistantContent: string,
		model?: string | null,
		excludeReasoning?: boolean
	): Promise<void> {
		this.cancelPreEncode();
		this.preEncodeAbortController = new AbortController();

		const signal = this.preEncodeAbortController.signal;

		try {
			const allIdle = await ChatService.areAllSlotsIdle(model, signal);

			if (!allIdle || signal.aborted) return;

			const messagesWithAssistant: DatabaseMessage[] = [
				...allMessages,
				{ ...assistantMessage, content: assistantContent }
			];

			await ChatService.preEncode(messagesWithAssistant, model, excludeReasoning, signal);
		} catch (err) {
			if (!isAbortError(err)) {
				console.warn('[ChatStore] Pre-encode failed:', err);
			}
		}
	}
}

export const chatStore = new ChatStore();
