Files
openchamber/packages/ui/src/lib/chatDraftPersistence.ts
T
Bohdan Triapitsyn 85400459e9 perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
2026-07-21 20:52:20 +03:00

126 lines
4.5 KiB
TypeScript

import { normalizePath } from '@/lib/pathNormalization';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { countSyncPersistenceSerialization } from '@/sync/performance-diagnostics';
export type ChatDraftIdentity = {
runtimeKey: string;
directory: string;
sessionId: string | null;
};
export type ChatDraftSnapshot = {
text: string;
confirmedMentions: Set<string>;
};
type PersistedChatDraft = {
text: string;
confirmedMentions: string[];
touchedAt: number;
};
type PersistedChatDraftEnvelope = {
version: 2;
drafts: Record<string, PersistedChatDraft>;
};
const STORAGE_KEY = 'openchamber.chatDrafts.v2';
const MAX_DRAFTS = 50;
const storage = getDeferredSafeStorage();
const deletionListeners = new Set<(identity: ChatDraftIdentity) => void>();
let cachedRawEnvelope: string | null | undefined;
let cachedEnvelope: PersistedChatDraftEnvelope | undefined;
export const createChatDraftIdentity = (
runtimeKey: string,
directory: string | null | undefined,
sessionId: string | null,
): ChatDraftIdentity | null => {
const normalizedDirectory = normalizePath(directory);
if (!runtimeKey || !normalizedDirectory) return null;
return { runtimeKey, directory: normalizedDirectory, sessionId };
};
export const getChatDraftIdentityKey = (identity: ChatDraftIdentity): string =>
JSON.stringify([identity.runtimeKey, identity.directory, identity.sessionId]);
const readEnvelope = (): PersistedChatDraftEnvelope => {
const raw = storage.getItem(STORAGE_KEY);
if (raw === cachedRawEnvelope && cachedEnvelope) return cachedEnvelope;
try {
const parsed = JSON.parse(raw ?? '') as Partial<PersistedChatDraftEnvelope>;
if (parsed.version !== 2 || !parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
cachedRawEnvelope = raw;
cachedEnvelope = { version: 2, drafts: {} };
return cachedEnvelope;
}
const drafts: Record<string, PersistedChatDraft> = {};
for (const [key, value] of Object.entries(parsed.drafts)) {
if (!value || typeof value !== 'object') continue;
const draft = value as Partial<PersistedChatDraft>;
if (typeof draft.text !== 'string' || !Array.isArray(draft.confirmedMentions) || typeof draft.touchedAt !== 'number') continue;
drafts[key] = {
text: draft.text,
confirmedMentions: draft.confirmedMentions.filter((mention): mention is string => typeof mention === 'string'),
touchedAt: draft.touchedAt,
};
}
cachedRawEnvelope = raw;
cachedEnvelope = { version: 2, drafts };
return cachedEnvelope;
} catch {
storage.removeItem(STORAGE_KEY);
cachedRawEnvelope = null;
cachedEnvelope = { version: 2, drafts: {} };
return cachedEnvelope;
}
};
const writeEnvelope = (envelope: PersistedChatDraftEnvelope): void => {
const serialized = JSON.stringify(envelope);
cachedRawEnvelope = serialized;
cachedEnvelope = envelope;
countSyncPersistenceSerialization(serialized);
storage.setItem(STORAGE_KEY, serialized);
};
export const readChatDraft = (identity: ChatDraftIdentity | null): ChatDraftSnapshot => {
if (!identity) return { text: '', confirmedMentions: new Set() };
const persisted = readEnvelope().drafts[getChatDraftIdentityKey(identity)];
return persisted
? { text: persisted.text, confirmedMentions: new Set(persisted.confirmedMentions) }
: { text: '', confirmedMentions: new Set() };
};
export const writeChatDraft = (
identity: ChatDraftIdentity | null,
text: string,
confirmedMentions: Iterable<string>,
): void => {
if (!identity) return;
const envelope = readEnvelope();
const key = getChatDraftIdentityKey(identity);
const mentions = Array.from(new Set(confirmedMentions));
if (!text && mentions.length === 0) {
if (!(key in envelope.drafts)) return;
delete envelope.drafts[key];
} else {
envelope.drafts[key] = { text, confirmedMentions: mentions, touchedAt: Date.now() };
}
const retained = Object.entries(envelope.drafts)
.sort((left, right) => right[1].touchedAt - left[1].touchedAt)
.slice(0, MAX_DRAFTS);
writeEnvelope({ version: 2, drafts: Object.fromEntries(retained) });
};
export const clearChatDraft = (identity: ChatDraftIdentity, notify = false): void => {
writeChatDraft(identity, '', []);
if (notify) deletionListeners.forEach((listener) => listener(identity));
};
export const subscribeChatDraftDeletion = (listener: (identity: ChatDraftIdentity) => void): (() => void) => {
deletionListeners.add(listener);
return () => deletionListeners.delete(listener);
};