ui: Stores split refactor (#27240)

* ui: Extract server stream lifecycle from chatStore into ChatStreamManager

Discovery, attach/replay, resume retry and the remote-running snapshot
formed a cohesive cluster inside chatStore. It now lives in
chat-streams.svelte.ts as ChatStreamManager, owned by chatStore, which
keeps the public entry points as delegates so components are
unchanged. chatStore: 2877 -> 2418 lines.

* ui: Extract user interaction gates from agenticStore into AgenticGates

Tool permission requests, turn-limit continue prompts and queued
steering messages are the state the loop waits on between turns. They
had no coupling to session state, so they now live in
agentic-gates.svelte.ts; agenticStore keeps delegates so components
are unchanged. agenticStore: 1196 -> 1073 lines.

* ui: Compose MCP resources under mcpStore.resources

Resource state was a second import scope next to mcpStore. Consumers
now go through mcpStore.resources, so the MCP surface is one store;
mcp-resources.svelte.ts stays a separate file owned by mcpStore.

* ui: Reorganize stores into domain namespaces

* fix: Update stale doc comments

* ui: Consolidate conv running-state into a chat activity ledger

Running-state was split across chatStore.chatLoadingStates (local
pipes), ChatStreamManager.remoteRunningConvs (backend sessions) and
attachingConvs (attach lifecycle), unioned by hand in
getAllLoadingChats and cross-cleaned by setChatLoading calling
streams.clearRemoteRunning - the 'spinner ghosts until tab toggle'
workaround.

chatActivityStore now owns both sets with one transition per event:
markLocal / localEnded (local pipe end also drops the stale remote
hint, no cross-owner call) / applyRemoteSnapshot (diffed). The
sidebar reads chatStore.activity.loadingConvs through the unchanged
getAllLoadingChats entry point.

Consequences:
- isStreamingActive and its five manual writers are gone; isStreaming()
  now reports whether the active conversation has a live streaming
  pipe, which is what all four consumers (assistant row, stop action,
  context gauge, chat screen) actually check
- isLoading/isReasoning become derived from the per-conv maps plus
  the active conversation, dropping the manual resync in
  syncLoadingStateForChat and clearUIState
- attachingConvs and the last-attach coordination disappear from
  ChatStreamManager
- getAllStreamingChats (no consumers) is removed

* ui: Give store collaborators narrow host interfaces

Collaborators took 'host: typeof <store>', i.e. the store's entire
public surface, which is how chatStore's streamChatCompletion,
createAssistantMessage, getApiOptions and setStreamingActive got
widened to public. Replace with per-collaborator interfaces carrying
only the members each one drives:

- ChatStreamHost (chat/streams) - activity, processing, streaming
  states, abort controller, loading/streaming setters
- ChatFlowsHost (chat/flows) - streaming core, message creation,
  per-conv state setters
- McpHealthHost (mcp/health) - connection registry + reconnection
- ModelPropsHost / ModelStatusHost (models) - model rows, feed
  updates; the managers write modalities/status back onto the host's
  rows, so those members stay writable
- ConversationsPreferencesHost (conversations) - the active row and
  the conversation list

The store classes now declare 'implements <Host>' so the contract is
visible at the class level, and the 'import type { <store> }' back
references in the collaborators disappear entirely - the host
contract is local to each collaborator file, and collaborators can
no longer reach around their slice. Members stay public (structural
typing), but the collaborator side is now compiler-enforced.

* test: Chat Activity store test

* refactor: Cleanup

* chore: Remove legacy architecture docs

* ui: Memoize findMessageIndex for the streaming hot path

Streaming looks up the same message index on every chunk, a linear
scan of activeMessages each time. Cache the last lookup and reuse it
after validating the id still sits at the same position (O(1)); any
structural change to the array fails validation and falls back to a
full scan.

* ui: Throttle per-chunk stream state writes to localStorage

saveStreamState ran JSON.stringify + a synchronous localStorage.setItem
on every decoded chunk of the stream. The read loop now goes through a
new saveStreamStateThrottled (one write per conversation per 500ms,
latest value held pending); the public saveStreamState keeps its
immediate-write contract for stream start and pre-fetch, and also
resets the throttle window.

A pending offset is force-flushed at resume boundaries (resumeStream
reads the offset back from localStorage), on visibilitychange->hidden
and on pagehide, so a reload always finds a usable offset. The resume
offset only needs to be roughly current since the server retransmits
from a line boundary and the client discards its partial line.

Adds unit tests for the throttled/flush/clear interplay.

* ui: Compute context gauge timing stats in one pass

currentRead/Fresh/Cache/Output were separate deriveds, each running a
full reverse scan of activeMessages for the last assistant timings,
and cumulative ran its own forward scan plus an agentic filter - 4-5
O(n) passes per chunk while streaming. Replace with a single
summarizeAssistantTimings() pass (last assistant timings, last
agentic llm totals and the cumulative sums) feeding a shared derived
snapshot. Semantics unchanged, including the live-stats overrides and
the agentic llm-totals branch.

* agentic : clear session state when a conversation is deleted

Every conversation that ran an agentic flow left an AgenticSession in the
store forever; clearSession was never called. conversationsStore now
notifies deletion listeners and agenticStore drops the matching sessions,
avoiding a circular import back into conversationsStore.

* chat : extract ChatService.normalizeMessagesForApi

The DB->API message normalization (convert + drop empty system messages)
was duplicated in sendMessage, preEncode and the agentic flow. Extract it
into one shared method and call it from all three.

* sse : share record splitting and data extraction

splitSseRecords and extractSseDataPayload centralize the record-boundary
splitting and data: line extraction used by parseSseJsonStream and the
models status feed. chat.service keeps its own line-based parser for
resume support.

* api : delegate apiFetchWithParams to apiFetch

apiFetchWithParams duplicated apiFetch's headers/fetch/error handling
body-for-body; it only differs in URL construction. Build the URL and
delegate.

* chat flows : dedupe title, timings and cleanup handling

- conversationsStore.applyTitleFromContent centralizes the title-from-first-
  message logic duplicated in 5 places
- ChatProcessingStore.applyStreamTimings centralizes the onTimings handler
  shared by the chat and continue flows
- host.cleanupStreaming centralizes the loading/streaming/processing reset
  repeated across the continue flow's exit paths

* conversations : centralize conversation update mirroring

rename, pin, mcp override, reasoning effort and cwd all repeated the same
write-DB-then-mirror-into-list-and-active dance. A single
applyConversationUpdate(id, updates) on the host collapses all five and
removes the forgot-to-mirror-one-field bug class. Drops the redundant
array reassignment in setCwd (deep  field assignment is reactive).

* mcp : dedupe tool execution, server parsing and tool indexing

- executeTool delegates to executeToolByName (only diff was argument parsing)
- drop the private #parseServerSettings copy; use parseMcpServerSettings
- cache getServers() keyed on the raw config value (hot path)
- indexServerTools() unifies the three identical toolsIndex rebuild loops

Assisted-by: Claude

* mcp : share cursor pagination and tool indexing

- MCPService.paginate() collapses the identical do-while loops in
  listAllResources and listAllResourceTemplates
- promoteHealthCheckToConnection now uses indexServerTools like the other
  connect paths

Assisted-by: Claude

* database : share message parent-child bookkeeping

- addChildToParent() dedups the append-to-children update in createMessageBranch
  and createSystemMessage
- removeChildFromParent() dedups the remove-from-children cleanup in deleteMessage
  and deleteMessageCascading
- bulkAdd the cloned messages when forking a conversation instead of one add
  per message

Assisted-by: Claude

* chore: Lint/format

* fix: `pagehide` event from `window`

* refactor: Api Fetch util

* docs : rewrite architecture sections in README

Update the high-level diagram, routes, hooks, stores, services and data
flow tables to match the current UI structure (mcp/settings/search
routes, agentic/tools/mcp stores, MCPService/ToolsService/SandboxService,
/tools API). Fix stale architectural patterns for per-conversation state
and modality validation.

* chore : add ESLint rule for blank lines between accessors

Enforce a blank line between consecutive class accessors. The core
padding-line-between-statements rule does not cover class members, so a
local rule is needed.

* refactor : reorder store members and unify naming

Order store class members as public fields, private fields, constructor,
getters, public methods, then private methods. Normalize private naming
to the `private` keyword (drop `#` and the `_` prefix where there is no
matching public getter). Rename conversationsStore.init() to
initialize() to match the other stores.

* refactor : prefix lookup methods with get in agentic and chat stores

Unify bare-name lookup methods with the get* prefix used across the
other stores (mcp, models, tools, settings). Renames currentTurn,
totalToolCalls, lastError, streamingToolCall, executingToolCallId,
pendingPermissionRequest, pendingContinueRequest,
pendingSteeringMessageContent, pendingSteeringMessageExtras in the
agentic store and pendingMessageContent, pendingMessageExtras in the
chat store. Updates the two consuming components and a doc comment.

* refactor: Clean up comments in stores' and services' code

* chore : add ESLint rule for class member ordering

Enforce structural order (public fields -> private fields -> constructor ->
getters -> setters -> public methods -> private methods) with alphabetical
sorting within each group via perfectionist/sort-classes. Dependency
detection keeps Svelte $derived fields in a valid dependency order instead
of alphabetizing them, since Svelte rejects forward references.

Assisted-by: Claude

* refactor : reorder class members to match new ESLint rule

Apply the sort-classes rule across stores, services, hooks and utils.
Pure reordering - verified no logic changes by comparing sorted line
multisets before/after. All tests and svelte-check pass.
This commit is contained in:
Aleksander Grygier
2026-08-20 19:02:04 +02:00
committed by GitHub
parent 681c29d36a
commit 521a64cd01
120 changed files with 11220 additions and 12829 deletions
@@ -0,0 +1,510 @@
/**
* settingsStore - Application configuration and theme management
*
* Owns generation parameters, UI preferences and theme, persisted to
* localStorage with Svelte 5 runes. Applies the admin's server ui_settings
* as defaults on first visit; sampling parameters sync with the server via
* ParameterSyncService.
*/
import { browser } from '$app/environment';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
SETTINGS_KEYS,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { deviceStore } from '$lib/stores/device.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import type { SettingsExportType } from '$lib/types';
import {
configToParameterRecord,
getConfigValue,
normalizeFloatingPoint,
setConfigValue
} from '$lib/utils';
import { setMode } from 'mode-watcher';
class SettingsStore {
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
// True until a config exists in localStorage; gates the one-time
// application of server ui_settings defaults for new users.
private isFirstVisit = false;
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
return server;
});
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
}
}
return {
config: configToExport,
timestamp: Date.now(),
userOverrides: Array.from(this.userOverrides),
version: 1
};
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
const value =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
this.userOverrides.delete(key);
}
this.saveConfig();
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get diff between current settings and server defaults
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
this.config,
ParameterSyncService.getSyncableParameterKeys()
);
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Get parameter information including source for a specific parameter
*/
getParameterInfo(key: string) {
const propsDefaults = this.getServerDefaults();
const currentValue = getConfigValue(this.config, key);
return ParameterSyncService.getParameterInfo(
key,
currentValue ?? '',
propsDefaults,
this.userOverrides
);
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
*/
importSettings(data: SettingsExportType): void {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
// Restore config (theme is included in config)
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Persist to localStorage
this.saveConfig();
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
console.log('Settings imported successfully');
}
/**
* Initialize the settings store by loading from localStorage.
* Called by initStores() after migrations have run.
*/
initialize() {
if (!browser) return;
try {
this.loadConfig();
this.migrateLegacyTheme();
// Apply the persisted theme from config on initial load
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize settings store:', error);
}
}
/**
* Reset all settings to defaults.
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
/**
* Reset configuration to defaults
*/
resetConfig() {
this.config = { ...SETTING_CONFIG_DEFAULT };
this.saveConfig();
}
/**
* Reset a parameter to Server default (or UI default if no Server default)
*/
resetParameterToServerDefault(key: string): void {
const serverDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (serverDefaults[key] !== undefined) {
// sampling param known by server: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
this.saveConfig();
}
/**
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Initialize settings with props defaults when server properties are first loaded
* This sets up the default values from /props endpoint
*/
syncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
if (Object.keys(propsDefaults).length === 0) return;
const uiSettings = serverStore.uiSettings;
const uiSettingsKeys = new Set(uiSettings ? Object.keys(uiSettings) : []);
for (const [key, propsValue] of Object.entries(propsDefaults)) {
const currentValue = getConfigValue(this.config, key);
const normalizedCurrent = normalizeFloatingPoint(currentValue);
const normalizedDefault = normalizeFloatingPoint(propsValue);
// if user value matches server, it's not a real override
if (normalizedCurrent === normalizedDefault) {
this.userOverrides.delete(key);
if (!uiSettingsKeys.has(key) && getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined) {
setConfigValue(this.config, key, undefined);
}
}
}
// UI settings are the admin's defaults for new users: applied once on
// the first visit, never on later loads, so the user's config can
// diverge. "Reset to Default" is the explicit way back to the baseline.
// A first visit config carries factory values only, so a key that
// already diverges here was set by the user before the baseline could
// be reached, through the API key splash, and stays theirs.
if (uiSettings && this.isFirstVisit) {
this.isFirstVisit = false;
for (const [key, value] of Object.entries(uiSettings)) {
if (value === undefined || this.userOverrides.has(key)) continue;
if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) {
continue;
}
setConfigValue(this.config, key, value);
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
}
}
this.saveConfig();
console.log('User overrides after sync:', Array.from(this.userOverrides));
}
/**
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
this.config[key] = value;
if (ParameterSyncService.canSyncParameter(key as string)) {
const propsDefaults = this.getServerDefaults();
const propsDefault = propsDefaults[key as string];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key as string);
} else {
this.userOverrides.add(key as string);
}
}
}
this.saveConfig();
}
/**
*
*
* Import / Export
*
*
*/
/**
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
const propsDefaults = this.getServerDefaults();
for (const [key, value] of Object.entries(updates)) {
if (ParameterSyncService.canSyncParameter(key)) {
const propsDefault = propsDefaults[key];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key);
} else {
this.userOverrides.add(key);
}
}
}
}
this.saveConfig();
}
/**
* Update the theme setting.
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
setMode(newTheme as ColorMode);
}
/**
*
*
* Utilities (private helpers)
*
*
*/
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
}
/**
* Load configuration from localStorage
* Returns default values for missing keys to prevent breaking changes
*/
private loadConfig() {
if (!browser) return;
try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
// First visit: no stored config yet. Server ui_settings apply once in
// this state, then the user's config diverges freely.
this.isFirstVisit = storedConfigRaw === null;
const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...savedVal
};
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
// Load user overrides
const savedOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
);
this.userOverrides = new Set(savedOverrides);
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
this.config = { ...SETTING_CONFIG_DEFAULT };
this.userOverrides = new Set();
}
}
/**
* Migrate the legacy un-namespaced "theme" localStorage key into config.
* Previously theme was stored separately in localStorage("theme") — now it lives
* inside the config object alongside all other settings.
* After migration the legacy key is removed.
*/
private migrateLegacyTheme() {
if (!browser) return;
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
localStorage.removeItem('theme');
this.saveConfig();
setMode(legacyTheme as ColorMode);
}
}
/**
* Save the current configuration to localStorage
*/
private saveConfig() {
if (!browser) return;
try {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
localStorage.setItem(
USER_OVERRIDES_LOCALSTORAGE_KEY,
JSON.stringify(Array.from(this.userOverrides))
);
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
}
}
export const settingsStore = new SettingsStore();