Skip to content

Commit d036ff2

Browse files
fix(web): a save in one tab no longer erases what another tab saved
Client settings, the prompt stash and composer drafts each wrote their whole in-memory copy over localStorage, so a save in one tab erased what another tab had saved since it loaded. - Settings refresh from storage when another tab writes them, and a queued patch is reapplied over the refreshed snapshot. - The prompt stash starts each write from the stored entries, keeping this tab's in-memory version of entries it already holds. - Composer drafts write only the entries this tab changed since its last write over what storage holds now. Fixes #35010 AI-assisted; verified with the tests and tools listed above.
1 parent 0fde977 commit d036ff2

4 files changed

Lines changed: 155 additions & 13 deletions

File tree

‎apps/web/src/clientPersistenceStorage.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,18 @@ export function writeBrowserClientSettings(settings: ClientSettings): void {
2323

2424
setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema);
2525
}
26+
27+
/** Storage events fire only for other tabs' writes; a null key means storage was cleared. */
28+
export function subscribeBrowserClientSettingsWrites(onChange: () => void): () => void {
29+
if (!hasWindow()) {
30+
return () => undefined;
31+
}
32+
const handleStorage = (event: StorageEvent) => {
33+
if (event.key === CLIENT_SETTINGS_STORAGE_KEY || event.key === null) {
34+
onChange();
35+
}
36+
};
37+
const target = window;
38+
target.addEventListener("storage", handleStorage);
39+
return () => target.removeEventListener("storage", handleStorage);
40+
}

‎apps/web/src/composerDraftStore.ts‎

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,106 @@ type ComposerPersistState =
126126
| { capturedState: ComposerDraftStoreState }
127127
| PersistedComposerDraftStoreState;
128128

129+
const composerDraftBaseStorage =
130+
typeof localStorage !== "undefined" ? localStorage : createMemoryStorage();
131+
132+
interface ComposerDraftWrite {
133+
readonly text: string;
134+
/** This tab's own state as of the write; entries it still matches were not edited here. */
135+
readonly local: PersistedComposerDraftStoreState;
136+
readonly merged: PersistedComposerDraftStoreState;
137+
}
138+
let composerDraftLoadedState: PersistedComposerDraftStoreState | null = null;
139+
let composerDraftLastWrite: ComposerDraftWrite | null = null;
140+
let composerDraftPendingWrite: ComposerDraftWrite | null = null;
141+
142+
function readStoredComposerDraftState(): PersistedComposerDraftStoreState | null {
143+
const text = composerDraftBaseStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
144+
if (typeof text !== "string") return null;
145+
if (text === composerDraftLastWrite?.text) return composerDraftLastWrite.merged;
146+
try {
147+
const parsed = JSON.parse(text) as StorageValue<PersistedComposerDraftStoreState>;
148+
return parsed.version === COMPOSER_DRAFT_STORAGE_VERSION ? parsed.state : null;
149+
} catch {
150+
return null;
151+
}
152+
}
153+
154+
function mergeDraftRecord<V>(
155+
stored: Readonly<Record<string, V>> | undefined,
156+
local: Readonly<Record<string, V>>,
157+
baseline: Readonly<Record<string, V>>,
158+
): Record<string, V> {
159+
const merged = { ...stored };
160+
for (const key of new Set([...Object.keys(local), ...Object.keys(baseline)])) {
161+
if (Equal.equals(local[key], baseline[key])) continue;
162+
const value = local[key];
163+
if (value === undefined) delete merged[key];
164+
else merged[key] = value;
165+
}
166+
return merged;
167+
}
168+
169+
/** Writes only what this tab changed since its last write over what other tabs saved since. */
170+
function mergeComposerDraftStates(
171+
stored: PersistedComposerDraftStoreState,
172+
local: PersistedComposerDraftStoreState,
173+
baseline: PersistedComposerDraftStoreState,
174+
): PersistedComposerDraftStoreState {
175+
const pick = <K extends keyof PersistedComposerDraftStoreState>(key: K) => {
176+
const value = Equal.equals(local[key], baseline[key]) ? stored[key] : local[key];
177+
return value === undefined ? {} : { [key]: value };
178+
};
179+
return {
180+
draftsByThreadKey: mergeDraftRecord(
181+
stored.draftsByThreadKey,
182+
local.draftsByThreadKey,
183+
baseline.draftsByThreadKey,
184+
),
185+
draftThreadsByThreadKey: mergeDraftRecord(
186+
stored.draftThreadsByThreadKey,
187+
local.draftThreadsByThreadKey,
188+
baseline.draftThreadsByThreadKey,
189+
),
190+
logicalProjectDraftThreadKeyByLogicalProjectKey: mergeDraftRecord(
191+
stored.logicalProjectDraftThreadKeyByLogicalProjectKey,
192+
local.logicalProjectDraftThreadKeyByLogicalProjectKey,
193+
baseline.logicalProjectDraftThreadKeyByLogicalProjectKey,
194+
),
195+
...pick("stickyModelSelectionByProvider"),
196+
...pick("stickyOptionsByModelByProvider"),
197+
...pick("stickyActiveProvider"),
198+
};
199+
}
200+
201+
function serializeComposerDraftState(value: StorageValue<ComposerPersistState>): string {
202+
if (!("capturedState" in value.state)) {
203+
return JSON.stringify({ state: value.state, version: value.version });
204+
}
205+
const local = partializeComposerDraftStoreState(value.state.capturedState);
206+
const baseline = composerDraftLastWrite?.local ?? composerDraftLoadedState;
207+
const stored = baseline ? readStoredComposerDraftState() : null;
208+
const merged = stored && baseline ? mergeComposerDraftStates(stored, local, baseline) : local;
209+
const text = JSON.stringify({ state: merged, version: value.version });
210+
composerDraftPendingWrite = { text, local, merged };
211+
return text;
212+
}
213+
129214
const composerDebouncedStorage = createDeferredStorage<StorageValue<ComposerPersistState>>(
130-
typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(),
131-
(value) =>
132-
JSON.stringify({
133-
state:
134-
"capturedState" in value.state
135-
? partializeComposerDraftStoreState(value.state.capturedState)
136-
: value.state,
137-
version: value.version,
138-
}),
215+
{
216+
getItem: (name) => composerDraftBaseStorage.getItem(name),
217+
setItem: (name, text) => {
218+
composerDraftBaseStorage.setItem(name, text);
219+
if (composerDraftPendingWrite?.text === text)
220+
composerDraftLastWrite = composerDraftPendingWrite;
221+
},
222+
removeItem: (name) => {
223+
composerDraftBaseStorage.removeItem(name);
224+
composerDraftLoadedState = null;
225+
composerDraftLastWrite = null;
226+
},
227+
},
228+
serializeComposerDraftState,
139229
COMPOSER_PERSIST_DEBOUNCE_MS,
140230
);
141231

@@ -4359,6 +4449,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
43594449
),
43604450
);
43614451

4452+
composerDraftLoadedState = partializeComposerDraftStoreState(composerDraftStore.getState());
4453+
43624454
export const useComposerDraftStore = composerDraftStore;
43634455

43644456
export function subscribeComposerDraftModelReconciliation(input: {

‎apps/web/src/hooks/useSettings.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
supportsSharedSettingsSync,
3232
} from "@t3tools/client-runtime/state/shared-settings";
3333
import { ensureLocalApi } from "~/localApi";
34+
import { subscribeBrowserClientSettingsWrites } from "~/clientPersistenceStorage";
3435
import {
3536
getThemeDefinition,
3637
getThemePreviewSidebarArtwork,
@@ -75,6 +76,7 @@ let clientSettingsHydrationPromise: Promise<void> | null = null;
7576
let clientSettingsHydrationGeneration = 0;
7677
let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();
7778
let deferredClientSettingsPatchCount = 0;
79+
let unsubscribeOtherTabClientSettings: (() => void) | null = null;
7880

7981
function emitClientSettingsChange() {
8082
for (const listener of clientSettingsListeners) {
@@ -152,6 +154,9 @@ async function hydrateClientSettings(): Promise<void> {
152154
if (persistedSettings) {
153155
replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
154156
}
157+
unsubscribeOtherTabClientSettings ??= subscribeBrowserClientSettingsWrites(
158+
refreshClientSettingsFromOtherTab,
159+
);
155160
setClientSettingsHydrationStatus("ready");
156161
} catch (error) {
157162
if (hydrationGeneration === clientSettingsHydrationGeneration) {
@@ -175,6 +180,20 @@ async function hydrateClientSettings(): Promise<void> {
175180
return clientSettingsHydrationPromise;
176181
}
177182

183+
function refreshClientSettingsFromOtherTab(): void {
184+
void enqueueClientSettingsPersistence(async () => {
185+
const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
186+
if (persistedSettings) {
187+
replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
188+
}
189+
}).catch((error) => {
190+
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} refresh failed`, {
191+
operation: "refresh",
192+
...safeErrorLogAttributes(error),
193+
});
194+
});
195+
}
196+
178197
const defaultClientSettingsPersistence = (settings: ClientSettings): Promise<void> =>
179198
ensureLocalApi().persistence.setClientSettings(settings);
180199

@@ -205,11 +224,17 @@ export function persistClientSettingsPatch(
205224
if (clientSettingsHydrationStatus !== "ready") {
206225
await hydrateClientSettings();
207226
}
208-
replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
209227
} finally {
210228
deferredClientSettingsPatchCount -= 1;
211229
}
212230
}
231+
// Also reapplies an optimistic patch that a refresh from another tab replaced.
232+
const current = getClientSettingsSnapshot();
233+
if (
234+
!Object.entries(patch).every(([key, value]) => current[key as keyof ClientSettings] === value)
235+
) {
236+
replaceClientSettingsSnapshot({ ...current, ...patch });
237+
}
213238
await persist(getClientSettingsSnapshot());
214239
}).catch((error) => {
215240
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
@@ -755,6 +780,8 @@ export function __resetClientSettingsPersistenceForTests(): void {
755780
clientSettingsHydrationPromise = null;
756781
clientSettingsPersistenceQueue = Promise.resolve();
757782
deferredClientSettingsPatchCount = 0;
783+
unsubscribeOtherTabClientSettings?.();
784+
unsubscribeOtherTabClientSettings = null;
758785
clientSettingsListeners.clear();
759786
clientSettingsHydrationListeners.clear();
760787
}

‎apps/web/src/promptStashStore.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ function readPersistedEntries(): ReadonlyArray<PromptStashEntry> | null {
122122
}
123123
}
124124

125+
/** Starts each write from storage so entries other tabs stashed or took are not overwritten. */
126+
function latestEntries(memory: ReadonlyArray<PromptStashEntry>): ReadonlyArray<PromptStashEntry> {
127+
const stored = readPersistedEntries();
128+
if (!stored) return memory;
129+
const memoryById = new Map(memory.map((entry) => [entry.id, entry]));
130+
return stored.map((entry) => memoryById.get(entry.id) ?? entry);
131+
}
132+
125133
interface PromptStashStoreState {
126134
entries: ReadonlyArray<PromptStashEntry>;
127135
stashEntry: (entry: PromptStashEntry) => {
@@ -147,7 +155,7 @@ interface PromptStashStoreState {
147155
export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({
148156
entries: [],
149157
stashEntry: (entry) => {
150-
const nextEntries = [entry, ...get().entries];
158+
const nextEntries = [entry, ...latestEntries(get().entries)];
151159
const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null;
152160
const { written, durable } = persistEntries(nextEntries);
153161
// Failed writes must not duplicate the intact composer; eviction also commits only on success.
@@ -158,7 +166,7 @@ export const usePromptStashStore = create<PromptStashStoreState>()((set, get) =>
158166
return { evicted, written: true, durable };
159167
},
160168
takeEntry: (entryId) => {
161-
const entries = get().entries;
169+
const entries = latestEntries(get().entries);
162170
const entry = entries.find((candidate) => candidate.id === entryId) ?? null;
163171
if (!entry) return { entry: null, durable: true };
164172
const nextEntries = entries.filter((candidate) => candidate.id !== entryId);
@@ -167,7 +175,7 @@ export const usePromptStashStore = create<PromptStashStoreState>()((set, get) =>
167175
return { entry, durable };
168176
},
169177
finalizeEntryImages: (entryId, images) => {
170-
const entries = get().entries;
178+
const entries = latestEntries(get().entries);
171179
const index = entries.findIndex((candidate) => candidate.id === entryId);
172180
const existing = index === -1 ? undefined : entries[index];
173181
if (!existing) return { attached: false, durable: true };

0 commit comments

Comments
 (0)