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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 16:40:50 -07:00
parent 2eb9ca2d8f
commit e7f0181f70
5 changed files with 166 additions and 8 deletions

View File

@ -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 } }));
}

View File

@ -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 {

View File

@ -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);

View File

@ -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
// ============================================================================

View File

@ -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;