From 4e5c3179fc619c8629b61c1598a5c87746c24323 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 18 Apr 2026 16:02:23 +0200 Subject: [PATCH] feat(workbench): MRU fallback for active scene + atomic reorderScenes - pickActiveId now consults a per-device MRU list (top 5 recent scenes, stored in localStorage) when the current scene disappears (delete, sync pull, tier filter). Previously the fallback was always scenes[0], which could strand the user on whatever sorted first after a delete rather than the scene they were just on. - reorderScenes runs all per-scene order patches inside one Dexie rw-transaction. A partial failure previously left the scene list with gapped or duplicated `order` values visible to subscribers; the transaction makes the reorder all-or-nothing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/stores/workbench-scenes.svelte.ts | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts index fec45b2b8..74ec4e7ea 100644 --- a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts @@ -25,6 +25,8 @@ import { setSceneScopeTagIds } from './scene-scope.svelte'; const TABLE = 'workbenchScenes'; const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId'; +const MRU_LS_KEY = 'mana:workbench:sceneMru'; +const MRU_CAP = 5; const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ { appId: 'todo' }, @@ -61,6 +63,30 @@ function writeActiveIdToStorage(id: string | null) { } } +function readMruFromStorage(): string[] { + if (!browser) return []; + try { + const raw = localStorage.getItem(MRU_LS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; + } catch { + return []; + } +} + +/** Push `id` to the front of the MRU list, dedup, cap. Per-device only. */ +function bumpMru(id: string) { + if (!browser) return; + try { + const current = readMruFromStorage(); + const next = [id, ...current.filter((x) => x !== id)].slice(0, MRU_CAP); + localStorage.setItem(MRU_LS_KEY, JSON.stringify(next)); + } catch { + /* storage quota / disabled — ignore */ + } +} + function toScene(local: LocalWorkbenchScene): WorkbenchScene { return { id: local.id, @@ -95,7 +121,15 @@ async function ensureSeedScene(): Promise { function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null { if (scenes.length === 0) return null; - if (current && scenes.some((s) => s.id === current)) return current; + const ids = new Set(scenes.map((s) => s.id)); + if (current && ids.has(current)) return current; + // Fall back to the most recent scene (local per-device MRU) that still + // exists, so deleting or sync-pulling away from the current scene + // restores the user's last-used workbench rather than jumping them to + // whatever happens to sort first. + for (const id of readMruFromStorage()) { + if (ids.has(id)) return id; + } return scenes[0].id; } @@ -165,6 +199,7 @@ function openSubscription(): void { if (next !== activeSceneIdState) { activeSceneIdState = next; writeActiveIdToStorage(next); + if (next) bumpMru(next); } // Sync scope when scenes reload (init, sync pull, tab focus). const activeScope = visible.find((s) => s.id === (next ?? activeSceneIdState)); @@ -245,6 +280,7 @@ export const workbenchScenesStore = { if (!scenesState.some((s) => s.id === id)) return; activeSceneIdState = id; writeActiveIdToStorage(id); + bumpMru(id); // Sync scene scope for module queries const scene = scenesState.find((s) => s.id === id); setSceneScopeTagIds(scene?.scopeTagIds); @@ -272,6 +308,7 @@ export const workbenchScenesStore = { if (opts.setActive !== false) { activeSceneIdState = id; writeActiveIdToStorage(id); + bumpMru(id); } return id; }, @@ -340,10 +377,22 @@ export const workbenchScenesStore = { if (fromIdx === -1 || toIdx === -1) return; const [moved] = ordered.splice(fromIdx, 1); ordered.splice(toIdx, 0, moved); - // Renumber and persist only the rows whose order actually changed. - await Promise.all( - ordered.map((s, i) => (s.order === i ? null : patchScene(s.id, { order: i }))) - ); + // Atomic renumber — one rw-transaction over all changed rows so a + // partial failure can't leave the scene list with gapped or + // duplicated orders visible to other tabs. Only writes rows whose + // order actually changed. + const now = nowIso(); + await db.transaction('rw', TABLE, async () => { + const writes: Promise[] = []; + for (let i = 0; i < ordered.length; i++) { + const s = ordered[i]; + if (s.order === i) continue; + writes.push( + db.table(TABLE).update(s.id, { order: i, updatedAt: now }) + ); + } + await Promise.all(writes); + }); }, // ── Per-scene app mutations (operate on the active scene) ─