Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/hooks/use-session-storage-sync.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { useEffect } from 'react';
import { useSessionStore } from '@/lib/stores/session-store';
import { useSessionStore, type SessionState } from '@/lib/stores/session-store';

const SESSION_STORAGE_KEY = 'debate-trainer-storage';

interface PersistedSessionPayload {
state?: Partial<ReturnType<typeof useSessionStore.getState>>;
state?: Partial<SessionState>;
}

/** Another tab's snapshot, or null when it belongs to a different account. */
export function persistedSessionFromStorage(newValue: string, currentOwner: string | null): Partial<SessionState> | null {
const payload = JSON.parse(newValue) as PersistedSessionPayload;
if (!payload.state) return null;
const incomingOwner = payload.state.ownerUserId ?? null;
if (currentOwner && incomingOwner && incomingOwner !== currentOwner) return null;
return payload.state;
}

export function useSessionStorageSync() {
Expand All @@ -13,9 +22,8 @@ export function useSessionStorageSync() {
if (event.key !== SESSION_STORAGE_KEY || !event.newValue) return;

try {
const payload = JSON.parse(event.newValue) as PersistedSessionPayload;
if (!payload.state) return;
useSessionStore.setState(payload.state);
const next = persistedSessionFromStorage(event.newValue, useSessionStore.getState().ownerUserId);
if (next) useSessionStore.setState(next);
} catch (error) {
console.warn('[SessionStore] Failed to sync persisted state from another tab:', error);
}
Expand Down
21 changes: 21 additions & 0 deletions src/lib/stores/debate-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ interface DebateState {
setOneOff: (oneOff: boolean) => void;
openDialog: (dialog: DebateDialog, motion?: string) => void;
resetLocalData: () => void;
/** Drops another account's debate list before the server copy loads. */
prepareForUser: (userId: string) => void;
}

export function normalizeMotion(motion: string): string {
Expand Down Expand Up @@ -244,11 +246,18 @@ export const useDebateStore = create<DebateState>()(
hydrationEpoch += 1;
set({ ownerUserId: null, debates: [], currentDebateId: null, oneOff: false, hydrated: false, dialog: null, dialogMotion: '' });
},

prepareForUser: (userId) => {
if (get().ownerUserId === userId) return;
hydrationEpoch += 1;
set({ ownerUserId: userId, debates: [], currentDebateId: null, oneOff: false, hydrated: false, dialog: null, dialogMotion: '' });
},
}),
{
name: 'debatelab-current-debate',
storage: createJSONStorage(() => localStorage),
version: 1,
merge: mergePersistedDebateState,
partialize: (state) => ({
ownerUserId: state.ownerUserId,
debates: state.debates,
Expand All @@ -258,6 +267,18 @@ export const useDebateStore = create<DebateState>()(
),
);

/**
* A rehydrate that finishes after sign-in must not restore another account's
* debate list. Lists saved before an owner was recorded stay only until this
* account is bound.
*/
export function mergePersistedDebateState(persistedState: unknown, currentState: DebateState): DebateState {
const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial<DebateState> : {};
const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null;
if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState;
return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId };
}

export function selectCurrentDebate(state: Pick<DebateState, 'debates' | 'currentDebateId'>): Debate | null {
return state.debates.find((debate) => debate.id === state.currentDebateId) ?? null;
}
Expand Down
96 changes: 63 additions & 33 deletions src/lib/stores/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface FlowchartData {
}

export interface SessionState {
/** Account these persisted drafts belong to. Null until sign-in binds them. */
ownerUserId: string | null;
generateResult: GenerateOutput | null;
generateLoading: boolean;
/** The Generate section being regenerated; kept here so it survives leaving the page. */
Expand Down Expand Up @@ -235,6 +237,12 @@ export interface SessionState {
serverHydration: 'pending' | 'partial' | 'complete';
clearAllData: () => Promise<void>;
resetLocalData: () => void;
/**
* Binds persisted drafts to the signed-in account. A different account, or
* drafts saved before accounts were recorded, is cleared first so one
* browser cannot show the previous person's work.
*/
prepareForUser: (userId: string) => void;
clearSession: () => void;

pendingFlowchartData: FlowchartData | null;
Expand Down Expand Up @@ -285,6 +293,54 @@ function normalizeFolder(folder?: string | null) {
return trimmed || 'General';
}

/** Device-only work that must not cross accounts. Server lists reload after this. */
function sessionWorkForAccount(ownerUserId: string | null): Partial<SessionState> {
return {
ownerUserId,
serverHydration: 'pending',
savedCases: [],
savedAttacks: [],
savedBrainstorms: [],
savedFactoids: [],
savedChats: [],
savedFlowcharts: [],
savedCrossfires: [],
autosavedCases: [],
autosavedAttacks: [],
autosavedBrainstorms: [],
autosavedFactoids: [],
askChatId: null,
askChatDebateId: null,
askPersistedCount: 0,
sourceFolders: ['General'],
sourceLibrary: [],
generateResult: null,
attackResult: null,
brainstormResult: null,
factoidsResult: null,
askMessages: [],
askStreamingAssistant: null,
pendingFlowchartData: null,
generateForm: {},
generateResultDebateId: undefined,
attackForm: {},
brainstormForm: {},
factoidsForm: {},
};
}

/**
* Keeps a rehydrate that finishes after sign-in from putting another account's
* snapshot back. Drafts with no owner are kept only while nobody is bound yet;
* binding then clears them.
*/
export function mergePersistedSessionState(persistedState: unknown, currentState: SessionState): SessionState {
const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial<SessionState> : {};
const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null;
if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState;
return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId };
}

function mergeSourceFolders(currentFolders: string[], sources: Source[] = []) {
return Array.from(new Set([
'General',
Expand Down Expand Up @@ -460,6 +516,7 @@ export function parseSourceProvenance(value: unknown): SourceProvenance | undefi
export const useSessionStore = create<SessionState>()(
persist(
(set, get) => ({
ownerUserId: null as string | null,
generateResult: null as GenerateOutput | null,
generateLoading: false,
generateRegeneratingSection: null as string | null,
Expand Down Expand Up @@ -1265,39 +1322,11 @@ export const useSessionStore = create<SessionState>()(
askPersistedCount: 0,
});
},
resetLocalData: () => set({
serverHydration: 'pending',
savedCases: [],
savedAttacks: [],
savedBrainstorms: [],
savedFactoids: [],
savedChats: [],
savedFlowcharts: [],
savedCrossfires: [],
autosavedCases: [],
autosavedAttacks: [],
autosavedBrainstorms: [],
autosavedFactoids: [],
askChatId: null,
askChatDebateId: null,
askPersistedCount: 0,
sourceFolders: ['General'],
sourceLibrary: [],
generateResult: null,
attackResult: null,
brainstormResult: null,
factoidsResult: null,
askMessages: [],
askStreamingAssistant: null,
// Drafts belong to the signed-in user too; the next person on this
// browser must not see them prefilled.
pendingFlowchartData: null,
generateForm: {},
generateResultDebateId: undefined,
attackForm: {},
brainstormForm: {},
factoidsForm: {},
}),
resetLocalData: () => set(sessionWorkForAccount(null)),
prepareForUser: (userId) => {
if (get().ownerUserId === userId) return;
set(sessionWorkForAccount(userId));
},

clearSession: () => set({
generateResult: null,
Expand All @@ -1324,6 +1353,7 @@ export const useSessionStore = create<SessionState>()(
// older clients: the server replaces them on hydrate, and keeping them
// would resurrect purged legacy rows and debate-less sources.
version: 1,
merge: mergePersistedSessionState,
migrate: (persistedState) => {
const state = { ...(persistedState as Record<string, unknown>) };
for (const key of [
Expand Down
28 changes: 28 additions & 0 deletions src/lib/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type CoreFeatureTab = Exclude<FeatureTab, 'sparring'>;
type FeatureConfigs = Record<CoreFeatureTab, ProviderConfig> & Partial<Record<'sparring', ProviderConfig>>;

interface SettingsState {
/** Account these device settings belong to. Null until sign-in binds them. */
ownerUserId: string | null;
language: AppLanguage;
outputLanguage: AppLanguage;
theme: 'light' | 'dark' | 'system';
Expand All @@ -36,6 +38,8 @@ interface SettingsState {
setWikiResearchEnabled: (enabled: boolean) => void;
hydrateFromServer: () => Promise<void>;
resetLocalData: (preserveLanguage?: boolean) => void;
/** Drops another account's prompt and model choices before the server copy loads. */
prepareForUser: (userId: string) => void;
}

const DEFAULT_PROVIDER = 'openrouter';
Expand Down Expand Up @@ -205,9 +209,22 @@ async function persistMigratedFeatureConfigs(
}
}

/**
* A rehydrate that finishes after sign-in must not restore another account's
* prompt or model choices. Settings saved before an owner was recorded stay
* only until this account is bound.
*/
export function mergePersistedSettingsState(persistedState: unknown, currentState: SettingsState): SettingsState {
const saved = persistedState && typeof persistedState === 'object' ? persistedState as Partial<SettingsState> : {};
const savedOwner = typeof saved.ownerUserId === 'string' ? saved.ownerUserId : null;
if (currentState.ownerUserId && savedOwner !== currentState.ownerUserId) return currentState;
return { ...currentState, ...saved, ownerUserId: savedOwner ?? currentState.ownerUserId };
}

export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
ownerUserId: null as string | null,
language: 'en',
outputLanguage: 'en',
theme: 'system',
Expand Down Expand Up @@ -338,6 +355,7 @@ export const useSettingsStore = create<SettingsState>()(
resetLocalData: (preserveLanguage = false) => {
const language = preserveLanguage ? get().language : 'en';
set({
ownerUserId: null,
language,
outputLanguage: 'en',
theme: 'system',
Expand All @@ -347,10 +365,20 @@ export const useSettingsStore = create<SettingsState>()(
});
void applyAppLanguage(language);
},
prepareForUser: (userId) => {
if (get().ownerUserId === userId) return;
set({
ownerUserId: userId,
customSystemPrompt: undefined,
featureConfigs: getDefaultFeatureConfigs(),
wikiResearchEnabled: false,
});
},
}),
{
name: 'debate-trainer-settings',
version: 2,
merge: mergePersistedSettingsState,
migrate: (persistedState: unknown, version) => {
const saved = asRecord(persistedState);
if (!saved) return persistedState;
Expand Down
15 changes: 12 additions & 3 deletions src/lib/supabase/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const hydrateSessionStore = useSessionStore((state) => state.hydrateFromServer);
const prepareSessionStore = useSessionStore((state) => state.prepareForUser);
const hydrateSettingsStore = useSettingsStore((state) => state.hydrateFromServer);
const prepareSettingsStore = useSettingsStore((state) => state.prepareForUser);
const hydrateSparringStore = useSparringStore((state) => state.hydrateSavedSessions);
const prepareSparringStore = useSparringStore((state) => state.prepareForUser);
const prepareDebateStore = useDebateStore((state) => state.prepareForUser);
const resetSessionData = useSessionStore((state) => state.resetLocalData);
// A tool still writing for the previous account stops with its data.
const resetSessionStore = useCallback(() => {
Expand All @@ -120,7 +123,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const hydrateStores = (userId?: string) => {
if (!userId || hydratedUserId === userId) return;
hydratedUserId = userId;
// Bind before any paint of protected pages. A full-page sign-in (magic
// link, Google) never sees the previous account's in-tab sign-out, so
// the persisted snapshot has to be checked against this user here.
prepareSessionStore(userId);
prepareSettingsStore(userId);
prepareSparringStore(userId);
prepareDebateStore(userId);
Promise.resolve(hydrateSessionStore()).catch((err) => console.warn('[Auth] hydrateSessionStore failed:', err));
Promise.resolve(hydrateSettingsStore()).catch((err) => console.warn('[Auth] hydrateSettingsStore failed:', err));
Promise.resolve(hydrateSparringStore()).catch((err) => console.warn('[Auth] hydrateSparringStore failed:', err));
Expand Down Expand Up @@ -176,7 +185,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setSession(oauthSession);
currentUserIdRef.current = oauthSession.user?.id;
setJamUserId(oauthSession.user?.id);
setLoading(false);
hydrateStores(oauthSession.user?.id);
return;
}
Expand Down Expand Up @@ -224,7 +232,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setJamUserId(nextUserId);
setActiveAccessToken(newSession?.access_token ?? null, newSession?.user.id ?? null);
setSession(newSession);
setLoading(false);

// Provider access tokens are short-lived. Keep them only for this browser
// session; a later export must reauthorize if no fresh token is available.
Expand All @@ -241,6 +248,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
resetDebateStore();
useDebateContextStore.getState().resetLocalData();
clearGoogleTokens();
setLoading(false);
return;
}

Expand All @@ -254,13 +262,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}

hydrateStores(nextUserId);
setLoading(false);
});

return () => {
mounted = false;
authListener.subscription.unsubscribe();
};
}, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]);
}, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareDebateStore, prepareSessionStore, prepareSettingsStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]);

// sessionStorage holds this tab's Google token and sparring state.
const discardTabState = useCallback(() => {
Expand Down
Loading
Loading