From e7f0181f70f95e3f6dd4dc7b04479ecf5ee7febb Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 16:40:50 -0700 Subject: [PATCH] fix: stop cross-tab active tab fighting + add per-user tab persistence activeLayerId was being written to the shared Automerge CRDT on every tab switch, causing all open windows/devices to follow. Now active tab is local-only. Adds REST API + server-side storage so authenticated users' tab lists persist across sessions and devices. Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 7 +---- server/shell.ts | 55 +++++++++++++++++++++++++++++++++++++++- src/encryptid/schema.sql | 14 ++++++++++ src/encryptid/server.ts | 47 ++++++++++++++++++++++++++++++++++ website/canvas.html | 51 ++++++++++++++++++++++++++++++++++++- 5 files changed, 166 insertions(+), 8 deletions(-) diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 5904550..66c2d04 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -1042,13 +1042,8 @@ export class CommunitySync extends EventTarget { this.#syncToServer(); } - /** Set active layer */ + /** Set active layer — local-only, never broadcast to other tabs/devices */ setActiveLayer(layerId: string): void { - this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Switch to layer ${layerId}`), (doc) => { - doc.activeLayerId = layerId; - }); - this.#scheduleSave(); - this.#syncToServer(); this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } })); } diff --git a/server/shell.ts b/server/shell.ts index fcc71ef..b38f644 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -538,11 +538,64 @@ export function renderShell(opts: ShellOptions): string { // Track current module as recently used if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId); - // Helper: save current tab list to localStorage + // Helper: save current tab list to localStorage + server + let _tabSaveTimer = null; function saveTabs() { localStorage.setItem(TABS_KEY, JSON.stringify(layers)); + // Debounced server save for authenticated users + clearTimeout(_tabSaveTimer); + _tabSaveTimer = setTimeout(() => { + try { + const raw = localStorage.getItem('encryptid_session'); + if (!raw) return; + const session = JSON.parse(raw); + if (!session?.accessToken) return; + fetch('/api/user/tabs/' + encodeURIComponent(spaceSlug), { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken }, + body: JSON.stringify({ tabs: layers }), + }).catch(() => {}); + } catch(e) {} + }, 500); } + // Fetch tabs from server for authenticated users (merge with localStorage) + (function syncTabsFromServer() { + try { + const raw = localStorage.getItem('encryptid_session'); + if (!raw) return; + const session = JSON.parse(raw); + if (!session?.accessToken) return; + fetch('/api/user/tabs/' + encodeURIComponent(spaceSlug), { + headers: { 'Authorization': 'Bearer ' + session.accessToken }, + }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) { + // Server has nothing — push localStorage tabs up + saveTabs(); + return; + } + // Merge: union of moduleIds, server order wins for shared tabs + const serverMap = new Map(data.tabs.map(t => [t.moduleId, t])); + const localMap = new Map(layers.map(t => [t.moduleId, t])); + const merged = [...data.tabs]; + for (const [mid, lt] of localMap) { + if (!serverMap.has(mid)) merged.push(lt); + } + merged.forEach((l, i) => { l.order = i; }); + layers = merged; + // Ensure current module is present + if (!layers.find(l => l.moduleId === currentModuleId)) { + layers.push(makeLayer(currentModuleId, layers.length)); + } + tabBar.setLayers(layers); + saveTabs(); + }) + .catch(() => {}); + } catch(e) {} + })(); + // ── Tab cache: instant switching via show/hide DOM panes ── let tabCache = null; try { diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index f025bdd..fc9a7de 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -483,3 +483,17 @@ UPDATE trust_events SET authority = 'dev-ops' WHERE authority IN ('curation', 'm -- Add new CHECK constraint with updated values ALTER TABLE delegations ADD CONSTRAINT delegations_authority_check CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom')); + +-- ============================================================================ +-- USER TAB STATE (per-user tab persistence across devices) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS user_tab_state ( + user_id TEXT NOT NULL, + space_slug TEXT NOT NULL, + tabs JSONB NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, space_slug) +); + +CREATE INDEX IF NOT EXISTS idx_user_tab_state_user_id ON user_tab_state(user_id); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index d539cc7..219d421 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -1079,6 +1079,53 @@ app.delete('/api/user/addresses/:id', async (c) => { return c.json({ success: true }); }); +// ============================================================================ +// USER TAB STATE (per-user tab persistence across devices) +// ============================================================================ + +/** GET /api/user/tabs/:spaceSlug — return saved tabs for this user+space */ +app.get('/api/user/tabs/:spaceSlug', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const spaceSlug = c.req.param('spaceSlug'); + + const rows = await sql` + SELECT tabs, updated_at FROM user_tab_state + WHERE user_id = ${userId} AND space_slug = ${spaceSlug} + `; + + if (rows.length === 0) { + return c.json({ tabs: null, updatedAt: null }); + } + + return c.json({ tabs: rows[0].tabs, updatedAt: rows[0].updated_at }); +}); + +/** PUT /api/user/tabs/:spaceSlug — upsert tab list for this user+space */ +app.put('/api/user/tabs/:spaceSlug', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const userId = claims.sub as string; + const spaceSlug = c.req.param('spaceSlug'); + const body = await c.req.json(); + + if (!Array.isArray(body.tabs)) { + return c.json({ error: 'tabs must be an array' }, 400); + } + + await sql` + INSERT INTO user_tab_state (user_id, space_slug, tabs, updated_at) + VALUES (${userId}, ${spaceSlug}, ${JSON.stringify(body.tabs)}::jsonb, NOW()) + ON CONFLICT (user_id, space_slug) + DO UPDATE SET tabs = ${JSON.stringify(body.tabs)}::jsonb, updated_at = NOW() + `; + + return c.json({ success: true }); +}); + // ============================================================================ // ACCOUNT SETTINGS ENDPOINTS // ============================================================================ diff --git a/website/canvas.html b/website/canvas.html index dabb579..c971039 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2883,11 +2883,60 @@ tabBar.setLayers(layers); tabBar.setAttribute('active', 'layer-' + currentModuleId); - // Helper: save current tab list to localStorage + // Helper: save current tab list to localStorage + server + let _tabSaveTimer = null; function saveTabs() { localStorage.setItem(TABS_KEY, JSON.stringify(layers)); + clearTimeout(_tabSaveTimer); + _tabSaveTimer = setTimeout(() => { + try { + const raw = localStorage.getItem('encryptid_session'); + if (!raw) return; + const session = JSON.parse(raw); + if (!session?.accessToken) return; + fetch('/api/user/tabs/' + encodeURIComponent(communitySlug), { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken }, + body: JSON.stringify({ tabs: layers }), + }).catch(() => {}); + } catch(e) {} + }, 500); } + // Fetch tabs from server for authenticated users + (function syncTabsFromServer() { + try { + const raw = localStorage.getItem('encryptid_session'); + if (!raw) return; + const session = JSON.parse(raw); + if (!session?.accessToken) return; + fetch('/api/user/tabs/' + encodeURIComponent(communitySlug), { + headers: { 'Authorization': 'Bearer ' + session.accessToken }, + }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) { + saveTabs(); + return; + } + const serverMap = new Map(data.tabs.map(t => [t.moduleId, t])); + const localMap = new Map(layers.map(t => [t.moduleId, t])); + const merged = [...data.tabs]; + for (const [mid, lt] of localMap) { + if (!serverMap.has(mid)) merged.push(lt); + } + merged.forEach((l, i) => { l.order = i; }); + layers = merged; + if (!layers.find(l => l.moduleId === currentModuleId)) { + layers.push(makeLayer(currentModuleId, layers.length)); + } + tabBar.setLayers(layers); + saveTabs(); + }) + .catch(() => {}); + } catch(e) {} + })(); + // ── Tab events ── tabBar.addEventListener('layer-switch', (e) => { const { moduleId } = e.detail;