incognitolm commited on
Commit
fec84f7
Β·
1 Parent(s): aefe3d6
public/js/app.js CHANGED
@@ -1,7 +1,10 @@
1
  // app.js β€” bootstrap, input handling, sidebar, attach, paste
2
  import { send, on } from './ws.js';
3
  import { isAuthenticated, logout, getTempId, getClientId } from './auth.js';
4
- import { createNewSession, switchSession, currentSessionId, onSessionChange } from './sessions.js';
 
 
 
5
  import { submitMessage, renderSession, setActiveSession, getIsStreaming } from './chat.js';
6
  import { openAuthModal, closeModal, openPasteEditor } from './modals.js';
7
  import { openSettings, applyTheme } from './settings.js';
@@ -17,13 +20,15 @@ function collapseSidebar(){ sidebar?.classList.remove('expanded'); sidebar?.clas
17
  function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); }
18
 
19
  toggleBtn?.addEventListener('click', toggleSidebar);
20
- // Always start collapsed β€” user opens manually
21
 
22
- // ── New chat ──────────────────────────────────────────────────────────────
23
 
24
  document.getElementById('new-chat-btn')?.addEventListener('click', () => {
25
- createNewSession();
26
  if (window.innerWidth < 768) collapseSidebar();
 
 
 
27
  });
28
 
29
  // ── Session switching ─────────────────────────────────────────────────────
@@ -71,18 +76,15 @@ function triggerCenterSend() {
71
  if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); }
72
  clearFilePreviewRow();
73
 
74
- if (!currentSessionId) {
75
- // Create session first, then send after both 'created' AND 'switched' have fired
76
- const pendingText = text, pendingAttach = attachments;
77
- const unsub = onSessionChange((ev, s) => {
78
- if (ev !== 'switched' || !s) return; // wait for switched (which sets activeSessionId)
79
- unsub();
80
- doSend(pendingText, pendingAttach);
81
- });
82
- createNewSession();
83
- } else {
84
- doSend(text, attachments);
85
- }
86
  }
87
 
88
  // ── Bottom input (active chat) ────────────────────────────────────────────
 
1
  // app.js β€” bootstrap, input handling, sidebar, attach, paste
2
  import { send, on } from './ws.js';
3
  import { isAuthenticated, logout, getTempId, getClientId } from './auth.js';
4
+ import {
5
+ createNewSession, showWelcomeScreen,
6
+ switchSession, currentSessionId, onSessionChange,
7
+ } from './sessions.js';
8
  import { submitMessage, renderSession, setActiveSession, getIsStreaming } from './chat.js';
9
  import { openAuthModal, closeModal, openPasteEditor } from './modals.js';
10
  import { openSettings, applyTheme } from './settings.js';
 
20
  function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); }
21
 
22
  toggleBtn?.addEventListener('click', toggleSidebar);
 
23
 
24
+ // ── New chat β€” just show the welcome screen; don't create a session yet ───
25
 
26
  document.getElementById('new-chat-btn')?.addEventListener('click', () => {
27
+ showWelcomeScreen();
28
  if (window.innerWidth < 768) collapseSidebar();
29
+ // Clear the center input so the user starts fresh
30
+ const ci = document.getElementById('center-input');
31
+ if (ci) { ci.value = ''; autoResize(ci, 6); }
32
  });
33
 
34
  // ── Session switching ─────────────────────────────────────────────────────
 
76
  if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); }
77
  clearFilePreviewRow();
78
 
79
+ // Always create a new session when sending from the welcome screen,
80
+ // then wait for 'switched' before submitting.
81
+ const pendingText = text, pendingAttach = attachments;
82
+ const unsub = onSessionChange((ev, s) => {
83
+ if (ev !== 'switched' || !s) return;
84
+ unsub();
85
+ doSend(pendingText, pendingAttach);
86
+ });
87
+ createNewSession();
 
 
 
88
  }
89
 
90
  // ── Bottom input (active chat) ────────────────────────────────────────────
public/js/auth.js CHANGED
@@ -4,9 +4,11 @@ import { send, on } from './ws.js';
4
  const SUPABASE_URL = 'https://dpixehhdbtzsbckfektd.supabase.co';
5
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwaXhlaGhkYnR6c2Jja2Zla3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjExNDI0MjcsImV4cCI6MjA3NjcxODQyN30.nR1KCSRQj1E_evQWnE2VaZzg7PgLp2kqt4eDKP2PkpE';
6
 
7
- const AUTH_KEY = 'ipai_auth_v1';
8
- const TEMP_ID_KEY = 'ipai_temp_id';
9
- const CLIENT_ID_KEY= 'ipai_client_id';
 
 
10
 
11
  export let currentUser = null;
12
  export let userProfile = null;
@@ -71,10 +73,19 @@ export async function signUpWithEmail(email, password) {
71
  return data;
72
  }
73
 
 
 
 
 
 
 
 
74
  export async function loginWithOAuth(provider) {
75
  const redirectTo = encodeURIComponent(`${location.origin}/oauth-callback.html`);
76
- const url = `${SUPABASE_URL}/auth/v1/authorize?rd=https%3A%2F%2Fincognitolm-chat.hf.space%2Foauth-callback.html&provider=${provider}&redirect_to=${redirectTo}`;
77
- window.open(url, '_blank', 'width=520,height=640,noopener');
 
 
78
  }
79
 
80
  export async function logout() {
@@ -101,7 +112,7 @@ async function handleSupabaseSession(data) {
101
  saveAuth({ access_token: data.access_token, refresh_token: data.refresh_token, user: data.user });
102
 
103
  return new Promise((resolve, reject) => {
104
- const tempId = getTempId();
105
  const clientId = getClientId();
106
  send({ type: 'auth:login', accessToken: data.access_token, refreshToken: data.refresh_token,
107
  tempId, clientId });
@@ -170,21 +181,30 @@ on('ws:connected', async () => {
170
  }
171
  });
172
 
173
- // ── OAuth popup message ───────────────────────────────────────────────────
174
-
175
- window.addEventListener('message', async (e) => {
176
- if (e.origin !== location.origin) return;
177
- if (e.data?.type !== 'oauth:callback') return;
178
- const { access_token, refresh_token } = e.data;
179
- if (!access_token) return;
180
- try { await handleSupabaseSession({ access_token, refresh_token }); }
181
- catch (err) {
 
 
 
 
 
 
 
 
 
182
  import('./ui.js').then(({ showNotification }) =>
183
  showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
184
  }
185
  });
186
 
187
- // Also handle if page was opened after OAuth redirect (no popup)
188
  (function checkOAuthRedirect() {
189
  const params = new URLSearchParams(location.search);
190
  const t = params.get('t'), r = params.get('r');
@@ -194,6 +214,19 @@ window.addEventListener('message', async (e) => {
194
  }
195
  })();
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  // ── Sidebar profile ────────────────────────────��──────────────────────────
198
 
199
  export function updateSidebarProfile() {
@@ -221,4 +254,4 @@ export function updateSidebarProfile() {
221
  }
222
 
223
  on('auth:ok', updateSidebarProfile);
224
- on('auth:loggedOut', updateSidebarProfile);
 
4
  const SUPABASE_URL = 'https://dpixehhdbtzsbckfektd.supabase.co';
5
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRwaXhlaGhkYnR6c2Jja2Zla3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjExNDI0MjcsImV4cCI6MjA3NjcxODQyN30.nR1KCSRQj1E_evQWnE2VaZzg7PgLp2kqt4eDKP2PkpE';
6
 
7
+ const AUTH_KEY = 'ipai_auth_v1';
8
+ const TEMP_ID_KEY = 'ipai_temp_id';
9
+ const CLIENT_ID_KEY = 'ipai_client_id';
10
+ // Key written by oauth-callback.html so the main tab can pick it up
11
+ const OAUTH_PENDING_KEY = 'ipai_oauth_pending';
12
 
13
  export let currentUser = null;
14
  export let userProfile = null;
 
73
  return data;
74
  }
75
 
76
+ /**
77
+ * OAuth login via popup.
78
+ * The popup writes the tokens to localStorage under OAUTH_PENDING_KEY,
79
+ * then closes itself. This tab listens for the storage event and picks
80
+ * them up β€” no postMessage needed (works even when opener is null, e.g.
81
+ * on some mobile browsers or strict sandboxes).
82
+ */
83
  export async function loginWithOAuth(provider) {
84
  const redirectTo = encodeURIComponent(`${location.origin}/oauth-callback.html`);
85
+ const url = `${SUPABASE_URL}/auth/v1/authorize?provider=${provider}&redirect_to=${redirectTo}`;
86
+ window.open(url, '_blank', 'width=520,height=640,noopener,noreferrer');
87
+ // The storage listener below (window.addEventListener('storage', ...))
88
+ // will fire when the popup writes OAUTH_PENDING_KEY and complete the login.
89
  }
90
 
91
  export async function logout() {
 
112
  saveAuth({ access_token: data.access_token, refresh_token: data.refresh_token, user: data.user });
113
 
114
  return new Promise((resolve, reject) => {
115
+ const tempId = getTempId();
116
  const clientId = getClientId();
117
  send({ type: 'auth:login', accessToken: data.access_token, refreshToken: data.refresh_token,
118
  tempId, clientId });
 
181
  }
182
  });
183
 
184
+ // ── OAuth: localStorage-based token pickup ────────────────────────────────
185
+ // Works for both popup and redirect flows.
186
+ // The oauth-callback.html page writes { access_token, refresh_token } to
187
+ // localStorage[OAUTH_PENDING_KEY] then closes/redirects. The storage event
188
+ // fires in all other tabs from the same origin.
189
+
190
+ window.addEventListener('storage', async (e) => {
191
+ if (e.key !== OAUTH_PENDING_KEY || !e.newValue) return;
192
+ // Consume immediately so other tabs don't also try to log in
193
+ localStorage.removeItem(OAUTH_PENDING_KEY);
194
+ let tokens;
195
+ try { tokens = JSON.parse(e.newValue); } catch { return; }
196
+ if (!tokens?.access_token) return;
197
+ try {
198
+ await handleSupabaseSession(tokens);
199
+ import('./ui.js').then(({ showNotification }) =>
200
+ showNotification({ type: 'success', message: 'Signed in!', duration: 2500 }));
201
+ } catch (err) {
202
  import('./ui.js').then(({ showNotification }) =>
203
  showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
204
  }
205
  });
206
 
207
+ // Also handle same-tab redirect flow (no popup) β€” ?oauth=1&t=TOKEN&r=REFRESH
208
  (function checkOAuthRedirect() {
209
  const params = new URLSearchParams(location.search);
210
  const t = params.get('t'), r = params.get('r');
 
214
  }
215
  })();
216
 
217
+ // Legacy postMessage support (kept for backwards compat with old callback pages)
218
+ window.addEventListener('message', async (e) => {
219
+ if (e.origin !== location.origin) return;
220
+ if (e.data?.type !== 'oauth:callback') return;
221
+ const { access_token, refresh_token } = e.data;
222
+ if (!access_token) return;
223
+ try { await handleSupabaseSession({ access_token, refresh_token }); }
224
+ catch (err) {
225
+ import('./ui.js').then(({ showNotification }) =>
226
+ showNotification({ type: 'error', message: `Sign-in failed: ${err.message}`, duration: 4000 }));
227
+ }
228
+ });
229
+
230
  // ── Sidebar profile ────────────────────────────��──────────────────────────
231
 
232
  export function updateSidebarProfile() {
 
254
  }
255
 
256
  on('auth:ok', updateSidebarProfile);
257
+ on('auth:loggedOut', updateSidebarProfile);
public/js/sessions.js CHANGED
@@ -62,18 +62,25 @@ on('sessions:data', (msg) => {
62
  on('auth:ok', (msg) => {
63
  sessions = msg.sessions || [];
64
  renderSessions();
65
- if (sessions.length > 0 && !currentSessionId) {
 
 
66
  switchSession(sessions[0].id);
 
 
 
67
  }
68
  });
69
 
70
  on('auth:guestOk', (msg) => {
71
  sessions = msg.sessions || [];
72
  renderSessions();
73
- if (sessions.length === 0) {
74
- createNewSession();
75
- } else {
76
  switchSession(sessions[0].id);
 
 
 
77
  }
78
  });
79
 
@@ -99,6 +106,21 @@ on('sessions:imported', (msg) => {
99
 
100
  // ── Actions ───────────────────────────────────────────────────────────────
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  export function createNewSession() {
103
  send({ type: 'sessions:create' });
104
  }
@@ -207,7 +229,6 @@ function startInlineRename(el) {
207
  }
208
 
209
  function openSessionMenu(e, id) {
210
- const session = sessions.find(s => s.id === id);
211
  const items = [
212
  {
213
  label: 'Share', icon: 'πŸ”—',
@@ -261,4 +282,4 @@ function groupByDate(sessions) {
261
 
262
  function escHtml(str) {
263
  return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
264
- }
 
62
  on('auth:ok', (msg) => {
63
  sessions = msg.sessions || [];
64
  renderSessions();
65
+ // On login, show the most recent session if one exists,
66
+ // otherwise show the welcome screen (don't auto-create).
67
+ if (sessions.length > 0) {
68
  switchSession(sessions[0].id);
69
+ } else {
70
+ currentSessionId = null;
71
+ notify('switched', null);
72
  }
73
  });
74
 
75
  on('auth:guestOk', (msg) => {
76
  sessions = msg.sessions || [];
77
  renderSessions();
78
+ // Show welcome screen; session is created lazily when user sends first message.
79
+ if (sessions.length > 0) {
 
80
  switchSession(sessions[0].id);
81
+ } else {
82
+ currentSessionId = null;
83
+ notify('switched', null);
84
  }
85
  });
86
 
 
106
 
107
  // ── Actions ───────────────────────────────────────────────────────────────
108
 
109
+ /**
110
+ * createNewSession is now "lazy" for the new-chat button:
111
+ * the button just navigates to the welcome screen.
112
+ * An actual session is only created when the user sends their first message
113
+ * (handled in app.js triggerCenterSend).
114
+ *
115
+ * Call createNewSession() directly when you really need the session to exist
116
+ * immediately (e.g. from triggerCenterSend in app.js).
117
+ */
118
+ export function showWelcomeScreen() {
119
+ currentSessionId = null;
120
+ renderSessions(); // deselect active item in sidebar
121
+ notify('switched', null); // app.js will show the welcome view
122
+ }
123
+
124
  export function createNewSession() {
125
  send({ type: 'sessions:create' });
126
  }
 
229
  }
230
 
231
  function openSessionMenu(e, id) {
 
232
  const items = [
233
  {
234
  label: 'Share', icon: 'πŸ”—',
 
282
 
283
  function escHtml(str) {
284
  return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
285
+ }
public/oauth-callback.html CHANGED
@@ -16,42 +16,67 @@
16
  <body>
17
  <div class="box">
18
  <div class="spinner"></div>
19
- <p>Completing sign-in…</p>
20
  </div>
21
  <script>
22
- // Extract tokens from URL hash (Supabase implicit flow) or search params
23
  function getTokens() {
24
  const hash = new URLSearchParams(location.hash.replace('#', ''));
25
  const search = new URLSearchParams(location.search);
26
  return {
27
- access_token: hash.get('access_token') || search.get('access_token'),
28
- refresh_token: hash.get('refresh_token') || search.get('refresh_token'),
29
- error: hash.get('error') || search.get('error'),
30
- error_desc: hash.get('error_description') || search.get('error_description'),
31
  };
32
  }
33
 
34
  const tokens = getTokens();
 
35
 
36
  if (tokens.error) {
37
- document.querySelector('p').textContent = 'Sign-in error: ' + (tokens.error_desc || tokens.error);
38
  } else if (tokens.access_token) {
39
- // Post to opener (main tab) and close
 
 
 
 
 
 
 
 
 
40
  if (window.opener && !window.opener.closed) {
41
- window.opener.postMessage({
42
- type: 'oauth:callback',
43
- access_token: tokens.access_token,
44
- refresh_token: tokens.refresh_token,
45
- }, location.origin);
46
- window.close();
47
- } else {
48
- // No opener β€” redirect to main page with params
49
- const p = new URLSearchParams({ oauth: '1', t: tokens.access_token, r: tokens.refresh_token || '' });
50
- location.replace('/?' + p.toString());
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  } else {
53
- document.querySelector('p').textContent = 'No token received. Close this window.';
54
  }
55
  </script>
56
  </body>
57
- </html>
 
16
  <body>
17
  <div class="box">
18
  <div class="spinner"></div>
19
+ <p id="msg">Completing sign-in…</p>
20
  </div>
21
  <script>
22
+ // ── Extract tokens from URL hash (Supabase implicit flow) or search params ──
23
  function getTokens() {
24
  const hash = new URLSearchParams(location.hash.replace('#', ''));
25
  const search = new URLSearchParams(location.search);
26
  return {
27
+ access_token: hash.get('access_token') || search.get('access_token'),
28
+ refresh_token: hash.get('refresh_token') || search.get('refresh_token'),
29
+ error: hash.get('error') || search.get('error'),
30
+ error_desc: hash.get('error_description') || search.get('error_description'),
31
  };
32
  }
33
 
34
  const tokens = getTokens();
35
+ const msgEl = document.getElementById('msg');
36
 
37
  if (tokens.error) {
38
+ msgEl.textContent = 'Sign-in error: ' + (tokens.error_desc || tokens.error);
39
  } else if (tokens.access_token) {
40
+ // ── Primary path: write to localStorage so the main tab picks it up
41
+ // via a 'storage' event listener β€” works even when window.opener is null
42
+ // (strict popup policies, mobile browsers, etc.).
43
+ const payload = JSON.stringify({
44
+ access_token: tokens.access_token,
45
+ refresh_token: tokens.refresh_token || '',
46
+ });
47
+ localStorage.setItem('ipai_oauth_pending', payload);
48
+
49
+ // ── Also try postMessage to opener for fastest possible handoff ──────
50
  if (window.opener && !window.opener.closed) {
51
+ try {
52
+ window.opener.postMessage({
53
+ type: 'oauth:callback',
54
+ access_token: tokens.access_token,
55
+ refresh_token: tokens.refresh_token,
56
+ }, location.origin);
57
+ } catch (_) { /* cross-origin opener β€” storage event is enough */ }
 
 
 
58
  }
59
+
60
+ msgEl.textContent = 'Sign-in complete! Closing…';
61
+ // Give localStorage a moment to propagate, then close.
62
+ setTimeout(() => {
63
+ // If we're in a popup, close it.
64
+ if (window.opener !== null || window.history.length <= 1) {
65
+ window.close();
66
+ }
67
+ // If close() didn't work (e.g. tab opened directly), redirect home.
68
+ setTimeout(() => {
69
+ const p = new URLSearchParams({
70
+ oauth: '1',
71
+ t: tokens.access_token,
72
+ r: tokens.refresh_token || '',
73
+ });
74
+ location.replace('/?' + p.toString());
75
+ }, 800);
76
+ }, 300);
77
  } else {
78
+ msgEl.textContent = 'No token received. You can close this window.';
79
  }
80
  </script>
81
  </body>
82
+ </html>
server/chatStream.js CHANGED
@@ -1,38 +1,43 @@
1
  import OpenAI from "openai";
2
- import { Client } from "@gradio/client";
3
  import { LIGHTNING_BASE } from "./config.js";
4
 
5
- // ── Gradio search client singleton ─────────────────────────────────────────
6
- // Connect once at module load and reuse across all searches.
7
- // If the connection drops, gradioClient is reset to null so the next call
8
- // reconnects automatically rather than retrying a dead client forever.
9
- let gradioClient = null;
10
- let gradioConnecting = null; // in-flight connect promise, prevents thundering herd
11
-
12
- async function getGradioClient() {
13
- if (gradioClient) return gradioClient;
14
- if (gradioConnecting) return gradioConnecting;
15
- gradioConnecting = Client.connect("incognitolm/Web-Search")
16
- .then(c => { gradioClient = c; gradioConnecting = null; return c; })
17
- .catch(e => { gradioConnecting = null; throw e; });
18
- return gradioConnecting;
19
- }
20
 
21
  async function gradioSearch(query) {
22
- // Try once; if it fails reset the singleton so the next call gets a fresh connection.
23
- try {
24
- const client = await getGradioClient();
25
- const r = await client.predict("/perform_search", { query });
26
- // r.data is an array; the search results are in the first element.
27
- // Accept a string, an array, or an object β€” normalise all to a string.
28
- const raw = Array.isArray(r.data) ? r.data[0] : r.data;
29
- if (!raw) throw new Error("Empty response from search endpoint");
30
- return typeof raw === "string" ? raw : JSON.stringify(raw);
31
- } catch (err) {
32
- // Invalidate the singleton so the next search attempt reconnects.
33
- gradioClient = null;
34
- throw err;
 
 
 
 
 
 
 
 
 
 
35
  }
 
36
  }
37
 
38
  const SYSTEM_PROMPT =
 
1
  import OpenAI from "openai";
 
2
  import { LIGHTNING_BASE } from "./config.js";
3
 
4
+ // ── Web Search via direct HTTP (no Gradio WebSocket) ──────────────────────
5
+ // The Gradio @gradio/client uses its own persistent WebSocket connection.
6
+ // Running it inside a Node WS server caused the server's WS events to be
7
+ // corrupted, showing "connection lost" to the browser and breaking all
8
+ // subsequent messages. We now call the Gradio Space's HTTP /run/predict
9
+ // endpoint directly instead, which is stateless and has no side effects.
10
+
11
+ const SEARCH_SPACE_HTTP = "https://incognitolm-web-search.hf.space";
12
+ // Some Spaces expose /api/predict or /run/predict depending on SDK version.
13
+ // We try /run/predict first (newer), fall back to /api/predict.
 
 
 
 
 
14
 
15
  async function gradioSearch(query) {
16
+ const body = JSON.stringify({ data: [query] });
17
+ const headers = { "Content-Type": "application/json" };
18
+
19
+ // Try the modern endpoint first
20
+ for (const path of ["/run/predict", "/api/predict"]) {
21
+ try {
22
+ const res = await fetch(`${SEARCH_SPACE_HTTP}${path}`, {
23
+ method: "POST",
24
+ headers,
25
+ body,
26
+ // Give the search up to 30 s before giving up
27
+ signal: AbortSignal.timeout(30_000),
28
+ });
29
+ if (!res.ok) continue;
30
+ const json = await res.json();
31
+ // Gradio returns { data: [...] }
32
+ const raw = Array.isArray(json.data) ? json.data[0] : json.data;
33
+ if (!raw) throw new Error("Empty response from search endpoint");
34
+ return typeof raw === "string" ? raw : JSON.stringify(raw);
35
+ } catch (err) {
36
+ // Try next path
37
+ if (path === "/api/predict") throw err; // last option, rethrow
38
+ }
39
  }
40
+ throw new Error("All search endpoints failed");
41
  }
42
 
43
  const SYSTEM_PROMPT =
server/sessionStore.js CHANGED
@@ -51,16 +51,37 @@ export const sessionStore = {
51
  },
52
  deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); },
53
  deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); },
 
 
 
 
 
 
 
 
 
54
  async transferTempToUser(tempId, userId, accessToken) {
55
- const d = tempStore.get(tempId); if (!d || !d.sessions.size) return;
56
- const uc = userClient(accessToken);
 
 
57
  const user = this._ensureUser(userId);
 
58
  for (const s of d.sessions.values()) {
59
- user.sessions.set(s.id, s);
60
- await this._persist(uc, userId, s).catch(() => {});
 
 
 
 
 
 
 
 
 
61
  }
62
- tempStore.delete(tempId);
63
  },
 
64
  // ── USERS ────────────────────────────────────────────────────────────────
65
  _ensureUser(uid) {
66
  if (!userCache.has(uid)) userCache.set(uid, { sessions: new Map(), online: new Set() });
@@ -93,28 +114,19 @@ export const sessionStore = {
93
  return s;
94
  },
95
  async deleteUserSession(userId, accessToken, id) {
96
- // β‘  Remove the .catch() from the query chain
97
- // β‘‘ Use try/catch to surface any unexpected errors
98
  try {
99
- // Remove the session from the in‑memory cache
100
  userCache.get(userId)?.sessions.delete(id);
101
-
102
- // Perform the actual DB delete
103
  const { error } = await userClient(accessToken)
104
  .from('web_sessions')
105
  .delete()
106
  .eq('id', id)
107
  .eq('user_id', userId);
108
-
109
- if (error) {
110
- console.error('Supabase delete error:', error.message);
111
- }
112
  } catch (ex) {
113
  console.error('Unexpected deleteUserSession error:', ex);
114
  }
115
  },
116
  async deleteAllUserSessions(userId, accessToken) {
117
- // Clear the in‑memory store first
118
  const u = userCache.get(userId);
119
  if (u) {
120
  u.sessions.clear();
@@ -122,17 +134,12 @@ export const sessionStore = {
122
  console.log('No user for ' + userId);
123
  return null;
124
  }
125
-
126
- // Delete everything from Supabase
127
  try {
128
  const { error } = await userClient(accessToken)
129
  .from('web_sessions')
130
  .delete()
131
  .eq('user_id', userId);
132
-
133
- if (error) {
134
- console.error('Supabase bulk delete error:', error.message);
135
- }
136
  } catch (ex) {
137
  console.error('Unexpected deleteAllUserSessions error:', ex);
138
  }
@@ -190,4 +197,4 @@ export const deviceSessionStore = {
190
  const s = devSessions.get(token); if (!s || !s.active) return null;
191
  s.lastSeen = new Date().toISOString(); return s;
192
  },
193
- };
 
51
  },
52
  deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); },
53
  deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); },
54
+
55
+ /**
56
+ * Copy temp sessions into the user's account on login.
57
+ * We intentionally do NOT delete from tempStore so the guest session
58
+ * remains usable if the user logs out again (and so the WS client's
59
+ * tempId still resolves while the tab is open).
60
+ * Sessions that already exist in the user account (same id) are skipped
61
+ * to avoid overwriting newer server data.
62
+ */
63
  async transferTempToUser(tempId, userId, accessToken) {
64
+ const d = tempStore.get(tempId);
65
+ if (!d || !d.sessions.size) return;
66
+
67
+ const uc = userClient(accessToken);
68
  const user = this._ensureUser(userId);
69
+
70
  for (const s of d.sessions.values()) {
71
+ // Skip sessions that are empty (never actually used)
72
+ if (!s.history || s.history.length === 0) continue;
73
+
74
+ // Skip if the user already has a session with the same id
75
+ if (user.sessions.has(s.id)) continue;
76
+
77
+ // Deep-clone so mutations to the user copy don't affect temp copy
78
+ const copy = JSON.parse(JSON.stringify(s));
79
+ user.sessions.set(copy.id, copy);
80
+ await this._persist(uc, userId, copy).catch(err =>
81
+ console.error('transferTempToUser persist error:', err.message));
82
  }
 
83
  },
84
+
85
  // ── USERS ────────────────────────────────────────────────────────────────
86
  _ensureUser(uid) {
87
  if (!userCache.has(uid)) userCache.set(uid, { sessions: new Map(), online: new Set() });
 
114
  return s;
115
  },
116
  async deleteUserSession(userId, accessToken, id) {
 
 
117
  try {
 
118
  userCache.get(userId)?.sessions.delete(id);
 
 
119
  const { error } = await userClient(accessToken)
120
  .from('web_sessions')
121
  .delete()
122
  .eq('id', id)
123
  .eq('user_id', userId);
124
+ if (error) console.error('Supabase delete error:', error.message);
 
 
 
125
  } catch (ex) {
126
  console.error('Unexpected deleteUserSession error:', ex);
127
  }
128
  },
129
  async deleteAllUserSessions(userId, accessToken) {
 
130
  const u = userCache.get(userId);
131
  if (u) {
132
  u.sessions.clear();
 
134
  console.log('No user for ' + userId);
135
  return null;
136
  }
 
 
137
  try {
138
  const { error } = await userClient(accessToken)
139
  .from('web_sessions')
140
  .delete()
141
  .eq('user_id', userId);
142
+ if (error) console.error('Supabase bulk delete error:', error.message);
 
 
 
143
  } catch (ex) {
144
  console.error('Unexpected deleteAllUserSessions error:', ex);
145
  }
 
197
  const s = devSessions.get(token); if (!s || !s.active) return null;
198
  s.lastSeen = new Date().toISOString(); return s;
199
  },
200
+ };