1import { saveState } from "../../lib/desktopCommands";2import { getDesktopWindowMode } from "../../lib/windowMode";3import {4 pruneComposerDrafts,5 resolveActiveComposerDraftKey,6 serializeComposerDrafts,7} from "../composerDrafts";8
import { serializeCreationDrafts } from "../creationDrafts";
9import { saveDesktopStateCache } from "../localStateCache";
10import { normalizePersistedProviderState } from "../persistedProviderState";
11import { normalizePersistedProviderUiState } from "../providerUiState";
12import type { AppStoreState } from "../store.helpers";
13import {
14 type CachedDesktopUiState,
15 normalizeCloudSyncSettings,
16 normalizePrivacyTelemetrySettings,
17 type PersistedState,
18} from "../types";
19import { getEffectiveThreadLastEventSeq, RUNTIME } from "./runtimeState";
20
21const PERSIST_DEBOUNCE_MS = 300;
22const DESKTOP_CACHE_DEBOUNCE_MS = 120;
23
24let _persistTimer: ReturnType<typeof setTimeout> | null = null;
25let _desktopCacheTimer: ReturnType<typeof setTimeout> | null = null;
26
27/**
28 * Filters out draft threads from persistence.
29 *
30 * Draft threads (thread.draft === true) are ephemeral UI-only threads that
31 * exist until the first message is sent. They are NOT persisted to disk,
32 * so if the app crashes or closes before the first message, the draft is lost.
33 * This is intentional to avoid accumulating empty threads.
34 *
35 * Note: Draft threads can still be selected in the UI. The draft flag only
36 * affects persistence, not runtime behavior.
37 */
38function buildPersistableThreads(state: AppStoreState) {
39 return state.threads
40 .filter((thread) => thread.draft !== true && !thread.taskId)
41 .map((thread) => ({
42 ...thread,
43 lastEventSeq: getEffectiveThreadLastEventSeq(state, thread.id),
44 }));
45}
46
47function buildPersistedState(
48 state: AppStoreState,
49 options: { includeDraftAttachmentData?: boolean } = {},
50): PersistedState {
51 const providerState = normalizePersistedProviderState({
52 statusByName: state.providerStatusByName,
53 statusLastUpdatedAt: state.providerStatusLastUpdatedAt,
54 });
55 const providerUiState = normalizePersistedProviderUiState(state.providerUiState);
56 const threads = buildPersistableThreads(state);
57 const workspaces = state.workspaces.map((workspace) => ({
58 ...workspace,
59 wsProtocol: "jsonrpc" as const,
60 }));
61 const prunedDrafts = pruneComposerDrafts(state.composerDraftsByKey ?? {}, {
62 validThreadIds: new Set(threads.map((thread) => thread.id)),
63 validProjectWorkspaceIds: new Set(
64 workspaces
65 .filter((workspace) => workspace.workspaceKind !== "oneOffChat")
66 .map((workspace) => workspace.id),
67 ),
68 activeKey: resolveActiveComposerDraftKey(state),
69 }).drafts;
70 const composerDrafts = serializeComposerDrafts(prunedDrafts);
71 if (options.includeDraftAttachmentData === false) {
72 for (const draft of Object.values(composerDrafts)) {
73 draft.attachments = [];
74 }
75 }
76
77 return {
78 version: 2,
79 workspaces,
80 threads,
81 developerMode: state.developerMode,
82 showHiddenFiles: state.showHiddenFiles,
83 perWorkspaceSettings: state.perWorkspaceSettings,
84 desktopSettings: state.desktopSettings,
85 privacyTelemetrySettings: normalizePrivacyTelemetrySettings(state.privacyTelemetrySettings),
86 cloudSync: normalizeCloudSyncSettings(state.cloudSync),
87 desktopFeatureFlagOverrides: state.desktopFeatureFlagOverrides,
88 ...(providerState ? { providerState } : {}),
89 providerUiState,
90 onboarding: state.onboardingState,
91 composerDrafts,
92 creationDrafts: serializeCreationDrafts(state),
93 };
94}
95
96function buildCachedDesktopUiState(state: AppStoreState): CachedDesktopUiState {
97 const persistedThreadIds = new Set(buildPersistableThreads(state).map((thread) => thread.id));
98 const selectedThreadId =
99 state.selectedThreadId && persistedThreadIds.has(state.selectedThreadId)
100 ? state.selectedThreadId
101 : null;
102
103 return {
104 selectedWorkspaceId: state.selectedWorkspaceId,
105 selectedThreadId,
106 selectedTaskId: state.selectedTaskId,
107 view: state.view,
108 settingsPage: state.settingsPage,
109 lastNonSettingsView: state.lastNonSettingsView,
110 sidebarCollapsed: state.sidebarCollapsed,
111 sidebarWidth: state.sidebarWidth,
112 contextSidebarCollapsed: state.contextSidebarCollapsed,
113 contextSidebarWidth: state.contextSidebarWidth,
114 canvasSidebarWidth: state.canvasSidebarWidth,
115 messageBarHeight: state.messageBarHeight,
116 newChatLandingTarget: state.newChatLandingTarget,
117 };
118}
119
120function syncDesktopStateCacheState(state: AppStoreState): PersistedState {
121 const persistedState = buildPersistedState(state);
122 if (getDesktopWindowMode() !== "main") {
123 return persistedState;
124 }
125 saveDesktopStateCache({
126 version: 2,
127 persistedState: buildPersistedState(state, { includeDraftAttachmentData: false }),
128 ui: buildCachedDesktopUiState(state),
129 sessionSnapshots: Object.fromEntries(RUNTIME.sessionSnapshots.entries()),
130 });
131 return persistedState;
132}
133
134export function syncDesktopStateCache(get: () => AppStoreState) {
135 if (_desktopCacheTimer) {
136 clearTimeout(_desktopCacheTimer);
137 }
138 _desktopCacheTimer = setTimeout(() => {
139 _desktopCacheTimer = null;
140 syncDesktopStateCacheState(get());
141 }, DESKTOP_CACHE_DEBOUNCE_MS);
142}
143
144export function syncDesktopStateCacheNow(get: () => AppStoreState) {
145 if (_desktopCacheTimer) {
146 clearTimeout(_desktopCacheTimer);
147 _desktopCacheTimer = null;
148 }
149 return syncDesktopStateCacheState(get());
150}
151
152/**
153 * Serialized form of the last state handed to the main process. Store updates
154 * arrive continuously (control events, provider refreshes, thread deltas) and
155 * most leave the persisted projection identical, so without this the debounce
156 * re-armed and rewrote the whole state file several times a second while idle.
157 * The local UI cache is still refreshed on every flush; only the write is
158 * skipped.
159 */
160let _lastPersistedJson: string | null = null;
161
162function shouldWriteState(state: PersistedState): boolean {
163 const serialized = JSON.stringify(state);
164 if (serialized === _lastPersistedJson) return false;
165 _lastPersistedJson = serialized;
166 return true;
167}
168
169export function persist(get: () => AppStoreState) {
170 if (_persistTimer) clearTimeout(_persistTimer);
171 _persistTimer = setTimeout(() => {
172 _persistTimer = null;
173 const state = syncDesktopStateCacheNow(get);
174 if (!shouldWriteState(state)) return;
175 void saveState(state);
176 }, PERSIST_DEBOUNCE_MS);
177}
178
179export async function persistNow(get: () => AppStoreState) {
180 if (_persistTimer) {
181 clearTimeout(_persistTimer);
182 _persistTimer = null;
183 }
184 const state = syncDesktopStateCacheNow(get);
185 if (!shouldWriteState(state)) return;
186 await saveState(state);
187}
188
189export const __internal = {
190 buildPersistableThreads,
191 resetPersistedStateCache: () => {
192 _lastPersistedJson = null;
193 },
194};
195