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
126 lines
4.5 KiB
TypeScript
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);
|
|
};
|