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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 16:02:23 +02:00
parent 0a928c1a56
commit 4e5c3179fc

View file

@ -25,6 +25,8 @@ import { setSceneScopeTagIds } from './scene-scope.svelte';
const TABLE = 'workbenchScenes'; const TABLE = 'workbenchScenes';
const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId'; const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId';
const MRU_LS_KEY = 'mana:workbench:sceneMru';
const MRU_CAP = 5;
const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [ const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [
{ appId: 'todo' }, { 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 { function toScene(local: LocalWorkbenchScene): WorkbenchScene {
return { return {
id: local.id, id: local.id,
@ -95,7 +121,15 @@ async function ensureSeedScene(): Promise<string> {
function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null { function pickActiveId(scenes: WorkbenchScene[], current: string | null): string | null {
if (scenes.length === 0) return 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; return scenes[0].id;
} }
@ -165,6 +199,7 @@ function openSubscription(): void {
if (next !== activeSceneIdState) { if (next !== activeSceneIdState) {
activeSceneIdState = next; activeSceneIdState = next;
writeActiveIdToStorage(next); writeActiveIdToStorage(next);
if (next) bumpMru(next);
} }
// Sync scope when scenes reload (init, sync pull, tab focus). // Sync scope when scenes reload (init, sync pull, tab focus).
const activeScope = visible.find((s) => s.id === (next ?? activeSceneIdState)); const activeScope = visible.find((s) => s.id === (next ?? activeSceneIdState));
@ -245,6 +280,7 @@ export const workbenchScenesStore = {
if (!scenesState.some((s) => s.id === id)) return; if (!scenesState.some((s) => s.id === id)) return;
activeSceneIdState = id; activeSceneIdState = id;
writeActiveIdToStorage(id); writeActiveIdToStorage(id);
bumpMru(id);
// Sync scene scope for module queries // Sync scene scope for module queries
const scene = scenesState.find((s) => s.id === id); const scene = scenesState.find((s) => s.id === id);
setSceneScopeTagIds(scene?.scopeTagIds); setSceneScopeTagIds(scene?.scopeTagIds);
@ -272,6 +308,7 @@ export const workbenchScenesStore = {
if (opts.setActive !== false) { if (opts.setActive !== false) {
activeSceneIdState = id; activeSceneIdState = id;
writeActiveIdToStorage(id); writeActiveIdToStorage(id);
bumpMru(id);
} }
return id; return id;
}, },
@ -340,10 +377,22 @@ export const workbenchScenesStore = {
if (fromIdx === -1 || toIdx === -1) return; if (fromIdx === -1 || toIdx === -1) return;
const [moved] = ordered.splice(fromIdx, 1); const [moved] = ordered.splice(fromIdx, 1);
ordered.splice(toIdx, 0, moved); ordered.splice(toIdx, 0, moved);
// Renumber and persist only the rows whose order actually changed. // Atomic renumber — one rw-transaction over all changed rows so a
await Promise.all( // partial failure can't leave the scene list with gapped or
ordered.map((s, i) => (s.order === i ? null : patchScene(s.id, { order: i }))) // 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<unknown>[] = [];
for (let i = 0; i < ordered.length; i++) {
const s = ordered[i];
if (s.order === i) continue;
writes.push(
db.table<LocalWorkbenchScene>(TABLE).update(s.id, { order: i, updatedAt: now })
);
}
await Promise.all(writes);
});
}, },
// ── Per-scene app mutations (operate on the active scene) ─ // ── Per-scene app mutations (operate on the active scene) ─