diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts b/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts index 28b574875..793ed0803 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts @@ -20,6 +20,7 @@ import { createManaLlmClient } from './llm-client'; import { runDueMissions, type MissionRunnerDeps } from './runner'; import { registerDefaultInputResolvers } from './default-resolvers'; import { runAgentsBootstrap } from '../agents/bootstrap'; +import { onActiveSpaceChanged } from '../../scope/active-space.svelte'; /** * Populate the seed-handler registry. Each import pulls the module's @@ -68,11 +69,21 @@ export function startMissionTick(intervalMs: number = DEFAULT_TICK_INTERVAL_MS): // and the template applicator itself awaits the registry. void ensureSeedsRegistered(); - // Multi-Agent Workbench: ensure a default "Mana" agent exists and - // backfill agentId on legacy missions. Fire-and-forget — the runner - // itself tolerates missions without an agentId during the migration - // window. See docs/plans/multi-agent-workbench.md §Phase 2d. + // Multi-Agent Workbench: ensure a default agent exists (space-aware + // since Phase 2d.3 — "Mana" in Personal, "Familien-Helfer" in + // Family, etc.) and backfill agentId on legacy missions. + // Fire-and-forget — the runner itself tolerates missions without an + // agentId during the migration window. See + // docs/plans/multi-agent-workbench.md §Phase 2d and + // docs/plans/space-scoped-data-model.md §2d.4. void runAgentsBootstrap(); + // And re-run when the active Space flips so every Space the user + // visits lands its own default agent the first time they land there. + // Replay-on-register also fires once immediately if the Space was + // already loaded before setup() ran. + onActiveSpaceChanged(() => { + void runAgentsBootstrap(); + }); const tickOnce = async () => { // Guard against overlap — a slow LLM run could pile up multiple ticks. diff --git a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts index 5ae4fe578..ee14c03a2 100644 --- a/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts @@ -28,6 +28,48 @@ let active = $state(null); let status = $state('idle'); let lastError = $state(null); +// ─── Change-handler subscribers ─────────────────────────────────── +// +// Other stores (workbench scenes, AI agents bootstrap, future Space- +// aware caches) register here and get notified whenever the active +// Space flips. Handlers are fire-and-forget: they can be async, but +// the space-switch flow does not wait for them. This keeps the +// primary path (user clicks a Space in the switcher) responsive, and +// lets each registered module own its own error handling. +// +// Newly-registered handlers are immediately replayed with the current +// active Space (if any) so they don't miss the first activation when +// the registration happens after loadActiveSpace already resolved. + +export type ActiveSpaceChangedHandler = (space: ActiveSpace | null) => void | Promise; + +const handlers: ActiveSpaceChangedHandler[] = []; + +export function onActiveSpaceChanged(h: ActiveSpaceChangedHandler): () => void { + handlers.push(h); + if (active && status === 'ready') { + try { + void h(active); + } catch (err) { + console.error('[active-space] handler replay failed:', err); + } + } + return () => { + const i = handlers.indexOf(h); + if (i >= 0) handlers.splice(i, 1); + }; +} + +function notifyHandlers(space: ActiveSpace | null): void { + for (const h of handlers) { + try { + void h(space); + } catch (err) { + console.error('[active-space] handler failed:', err); + } + } +} + export function getActiveSpace(): ActiveSpace | null { return active; } @@ -45,9 +87,11 @@ export function getActiveSpaceError(): string | null { } export function setActiveSpace(space: ActiveSpace | null): void { + const prevId = active?.id; active = space; status = space ? 'ready' : 'idle'; lastError = null; + if (space?.id !== prevId) notifyHandlers(space); } /** @@ -113,12 +157,14 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise typeof v === 'string') : []; @@ -75,13 +92,13 @@ function readMruFromStorage(): string[] { } } -/** Push `id` to the front of the MRU list, dedup, cap. Per-device only. */ +/** Push `id` to the front of the MRU list, dedup, cap. Per-device-per-Space. */ 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)); + localStorage.setItem(mruKey(getActiveSpaceId()), JSON.stringify(next)); } catch { /* storage quota / disabled — ignore */ } @@ -195,8 +212,18 @@ function openSubscription(): void { subscription = liveQuery(() => db.table(TABLE).toArray()).subscribe({ next: (rows) => { try { + // Filter by active Space before any UI sees the rows — scenes + // stamped against a different spaceId live in the same Dexie + // table but aren't part of this Space's workbench. Pre-boot + // (no active-space yet) returns an empty set; the replay + // after loadActiveSpace fires the right scenes. + const inScopeIds = getInScopeSpaceIds(); const visible = rows - .filter((r) => !r.deletedAt) + .filter((r) => { + if (r.deletedAt) return false; + const spaceId = (r as { spaceId?: unknown }).spaceId; + return typeof spaceId === 'string' && inScopeIds.includes(spaceId); + }) .sort((a, b) => a.order - b.order) .map(toScene); scenesState = visible; @@ -259,6 +286,10 @@ export const workbenchScenesStore = { if (!browser || initializedState) return; // Seed a Home scene on first run so the UI never has zero scenes. + // We can't safely check "none in this Space" until the active- + // space handler replays — the guard is "no scenes anywhere", same + // as before. Per-Space seeding happens inside onSpaceChanged + // below. const count = await db.table(TABLE).count(); if (count === 0) { await ensureSeedScene(); @@ -266,6 +297,33 @@ export const workbenchScenesStore = { activeSceneIdState = readActiveIdFromStorage(); openSubscription(); + + // Register a handler that refreshes per-Space state whenever the + // active Space flips. Replay-on-register fires once immediately if + // a Space is already loaded, so the initial state is correct + // regardless of which store finishes initializing first. + onActiveSpaceChanged(async (space) => { + // Update activeSceneIdState from the new Space's LS key. The + // liveQuery is already re-running because getInScopeSpaceIds() + // returns a different set, but the local activeSceneIdState + // needs an explicit re-read. + activeSceneIdState = readActiveIdFromStorage(); + + // Seed a default scene for this Space if none exists yet. Runs + // on the first visit to every Shared/Brand/Family/Team Space a + // user joins, so the workbench never shows empty. + if (space) { + const anyInSpace = await db + .table(TABLE) + .filter((r) => { + if (r.deletedAt) return false; + const spaceId = (r as { spaceId?: unknown }).spaceId; + return spaceId === space.id; + }) + .first(); + if (!anyInSpace) await ensureSeedScene(); + } + }); }, dispose() { diff --git a/docs/optimizable/bundle-analysis.md b/docs/optimizable/bundle-analysis.md new file mode 100644 index 000000000..9f6768cbc --- /dev/null +++ b/docs/optimizable/bundle-analysis.md @@ -0,0 +1,91 @@ +# Bundle Analysis + +**Snapshot 2026-04-22.** Run `pnpm run audit:bundle` after any `pnpm --filter @mana/web build` for live numbers. + +## Snapshot + +| Category | Size | % | Notes | +|---|---|---|---| +| entry | 92 KB | 0% | app.js + start.js — first JS a cold browser loads | +| nodes | 2.77 MB | 9% | per-route layout/page bundles (230 files) | +| chunks | 5.59 MB | 18% | shared code-split modules (711 files) | +| workers | 22.31 MB | 70% | transformers.js ONNX WASM — lazy until LLM/STT opened | +| assets | 1.04 MB | 3% | CSS, fonts, images | +| **total** | **31.80 MB** | | 1147 files | + +Entry is tiny (92 KB). That's healthy. The 22 MB in `workers/` is +`ort-wasm-simd-threaded.asyncify-*.wasm` — the ONNX Runtime for +transformers.js. It's only fetched when the browser actually +instantiates the Web Worker (first use of `@mana/local-llm` or +`@mana/local-stt`). Most users never hit it. + +## Biggest shared chunks + +Top entries in `chunks/` ≥ 200 KB, with what's actually inside: + +| Size | File | Content | Verdict | +|---|---|---|---| +| 797 KB | SDMVbHi1 | `@zxcvbn-ts/language-de` German dictionary | ✓ **lazy** — dynamic import in `PasswordStrength.svelte`, only on register / recovery | +| 454 KB | bdamX4EN | `@zxcvbn-ts/language-common` keyboard adjacency graphs | ✓ **lazy** — same import path | +| 317 KB | DtX-t1si | `@mana/shared-icons` (Phosphor SVG paths) | ⚠ **partly eager** — imported by many routes; see notes | +| 220 KB | BbeX9yAb | Vite `__vite__mapDeps` import-graph | ✓ **metadata only**, not real code | +| 162 KB | Bqmpszdn | (unknown) | below threshold | + +## Biggest route bundles + +Routes loaded per navigation (not eagerly): + +| Size | Route | Note | +|---|---|---| +| 534 KB | `/(app)/invoices/[id]` | **heaviest route.** Likely swissqrbill + pdf-lib. Investigate — could split the QR-bill generator behind `await import()` so preview/list pages stay small. | +| 380 KB | `/(app)/broadcasts/[id]/edit` | Tiptap editor — unavoidable, tiptap is ~250 KB baseline | +| 260 KB | node 2 (root `(app)` layout) | All app-wide chrome: shell, stores, auth guard | +| 95 KB | `/(app)/calendar` | Acceptable; rrule is shared | +| 85 KB | `/(app)/todo` | Acceptable | + +## Priority improvements + +1. **`/invoices/[id]` code-split** — 534 KB is large. `swissqrbill` + and `pdf-lib` are probably both eagerly imported. Wrap the PDF/QR + generation path with `await import('swissqrbill')` so the route + bundle drops to ~150 KB for the list/display case. Real win for + Swiss-bill users on 3G / slow laptops. + +2. **`@mana/shared-icons` chunking** — 317 + 149 KB of Phosphor SVG + paths across two chunks. Phosphor doesn't tree-shake well because + its icons are named exports of a single module. Either: + - migrate to tree-shakable per-icon imports, OR + - lazy-load rarely-used icons in the module that imports them + instead of the shared chunk. + +3. **Root `(app)` layout (260 KB)** — on the high side for "just the + shell". Investigate whether any module-specific stores / AI pipeline + bits are leaking into the shared layout when they could be + route-scoped. + +## What's already good + +- Entry bundle is 92 KB — no bloat in the critical path. +- 22 MB of WASM (transformers.js) is correctly behind Web Workers — + cold visitors don't pay for it. +- zxcvbn (1.25 MB German dict + keyboard graphs) is correctly behind a + dynamic import in PasswordStrength.svelte — only register / recovery + / password-change surfaces load it. +- Route-level code-splitting is working: 230 separate route bundles, + median ~12 KB. + +## Usage + +```bash +pnpm --filter @mana/web build # prerequisite +pnpm run audit:bundle # full report +pnpm run audit:bundle --top 30 # show top N chunks +pnpm run audit:bundle --summary # category totals only +``` + +Heuristic rules: chunks in `chunks/` with ≥ 200 KB get a ⚠ flag. +Route bundles (`nodes/`) and worker bundles (`workers/`) are +always-lazy and don't get flagged. The content-hint regex knows about +transformers.js, zxcvbn, tiptap, pdf-lib, swissqrbill, rrule, suncalc, +dexie, marked, pako, date-fns, zod, svelte-dnd-action, svelte-i18n, +WASM, Phosphor icons, and Vite's own mapDeps metadata. diff --git a/package.json b/package.json index 2deda5cf4..25e7cc3d8 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "audit:i18n-coverage": "node scripts/audit-i18n-coverage.mjs", "audit:port-drift": "node scripts/audit-port-drift.mjs", "audit:test-coverage": "node scripts/audit-test-coverage.mjs", + "audit:bundle": "node scripts/audit-bundle.mjs", "audit:all": "pnpm run audit:port-drift && pnpm run audit:i18n-coverage --summary && pnpm run audit:test-coverage --summary", "generate:dockerfiles": "node scripts/generate-dockerfiles.mjs", "setup:env": "node scripts/generate-env.mjs", diff --git a/scripts/audit-bundle.mjs b/scripts/audit-bundle.mjs new file mode 100644 index 000000000..686351c3d --- /dev/null +++ b/scripts/audit-bundle.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Audit the unified Mana web-app production bundle. + * + * Reads `.svelte-kit/output/client/_app/immutable/*` after `pnpm build` + * and reports size distribution by category + the biggest chunks, with + * hints about what's likely in each chunk. + * + * No extra deps: pure Node ESM walking the disk. Vite + SvelteKit have + * already done the code-splitting work by the time we read the output; + * this script just surfaces it. + * + * Usage: + * pnpm --filter @mana/web build # prerequisite + * node scripts/audit-bundle.mjs + * node scripts/audit-bundle.mjs --top 30 # show top N chunks + * node scripts/audit-bundle.mjs --summary # category totals only + * + * Bucketing: + * - entry : first JS a browser loads on cold visit (app + start). + * - nodes : per-route layout/page bundles (lazy by route). + * - chunks : shared code-split modules (loaded on demand by import). + * - workers : Web Worker bundles + their assets (incl. WASM). + * - assets : static assets (CSS, fonts, images). + * + * Flags: chunks > 200KB that aren't inside `workers/` or `nodes/` are + * worth inspecting — they're eagerly reachable from the shared runtime + * and contribute to every cold load's cost (unless behind a dynamic + * import, which appears as a separate chunk the browser only fetches + * when triggered). + */ + +import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const OUT = join(REPO_ROOT, 'apps/mana/apps/web/.svelte-kit/output/client/_app/immutable'); +const SERVER_MANIFEST = join(REPO_ROOT, 'apps/mana/apps/web/.svelte-kit/output/server/manifest.js'); + +/** Parse `/(app)/invoices/[id]` route → node-index pairs from the server + * manifest so we can map bundle filenames back to SvelteKit routes. */ +function loadRouteMap() { + const map = new Map(); // nodeIndex → routeId + if (!existsSync(SERVER_MANIFEST)) return map; + const src = readFileSync(SERVER_MANIFEST, 'utf8'); + // Matches the routes block entries like: + // id: "/(app)/invoices/[id]", + // ... + // page: { ..., leaf: 118 } + const entryRe = /id:\s*"([^"]+)"[\s\S]*?leaf:\s*(\d+)/g; + let m; + while ((m = entryRe.exec(src)) !== null) { + map.set(Number(m[2]), m[1]); + } + return map; +} + +const args = process.argv.slice(2); +const SUMMARY = args.includes('--summary'); +const TOP_IDX = args.indexOf('--top'); +const TOP_N = TOP_IDX >= 0 ? Number(args[TOP_IDX + 1] || 15) : 15; + +function walk(dir, rel = '') { + const out = []; + for (const ent of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, ent.name); + const r = rel ? `${rel}/${ent.name}` : ent.name; + if (ent.isDirectory()) out.push(...walk(p, r)); + else if (ent.isFile()) { + const size = statSync(p).size; + out.push({ abs: p, rel: r, size }); + } + } + return out; +} + +function classify(rel) { + if (rel.startsWith('entry/')) return 'entry'; + if (rel.startsWith('nodes/')) return 'nodes'; + if (rel.startsWith('workers/')) return 'workers'; + if (rel.startsWith('assets/')) return 'assets'; + if (rel.startsWith('chunks/')) return 'chunks'; + return 'other'; +} + +function fmtKB(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +/** Heuristic hint at what a chunk contains, based on a longer read. */ +function contentHint(abs) { + try { + const buf = readFileSync(abs, 'utf8').slice(0, 2000); + const pairs = [ + [/@huggingface\/transformers|transformers\.|TransformersJS/, 'transformers.js'], + [/adjacency.?graphs?|azerty:\{0:\["/i, 'zxcvbn keyboard graphs'], + [/ich,sie,das,ist,du,nicht|dictionaryRaw|frequencyLists/, 'zxcvbn language dictionary'], + [/tiptap|ProseMirror|prosemirror/i, 'tiptap / prosemirror'], + [/pdfLib|PDFDocument|%PDF-/i, 'pdf-lib'], + [/swissqrbill|swissqr|IBAN|QR-IBAN/i, 'swissqrbill'], + [/RRule|\bBYSETPOS\b|FREQ=DAILY/, 'rrule'], + [/suncalc|solarTimes|sunrise/, 'suncalc'], + [/dexie|_dbSchema|IDBObjectStore/, 'dexie'], + [/marked|lexer\.Token|parseInline/, 'marked'], + [/\bpako_inflate\b|\bpako\.deflate\b|\bZLIB\b/, 'pako'], + [/date-fns|format\w+\(|parseISO/, 'date-fns'], + [/zod|ZodError|z\.object/, 'zod'], + [/svelte-dnd-action|dndzone/, 'svelte-dnd-action'], + [/svelte-i18n|addMessages|register\(/, 'svelte-i18n'], + [/wasm|WebAssembly/i, 'WASM module'], + // Phosphor icon sprites — the chunk literally contains ` f.rel.endsWith('.js')) + .sort((a, b) => b.size - a.size) + .slice(0, TOP_N); + console.log(`Top ${topFiles.length} JS files by size (category + heuristic hint):\n`); + for (const f of topFiles) { + const cat = classify(f.rel); + let hint = '—'; + if (cat === 'nodes') { + const nodeIdx = Number(f.rel.match(/nodes\/(\d+)\./)?.[1]); + const route = routeMap.get(nodeIdx); + hint = route ? `route ${route}` : 'route bundle'; + } else if (cat !== 'assets') { + hint = contentHint(f.abs); + } + const flag = cat === 'chunks' && f.size > 200 * 1024 ? ' ⚠ eager?' : ''; + console.log( + ` ${fmtKB(f.size).padStart(9)} ${cat.padEnd(7)} ${hint.padEnd(36)} ${f.rel}${flag}` + ); + } + console.log(''); + + const topAssets = files + .filter((f) => !f.rel.endsWith('.js') && !f.rel.endsWith('.css')) + .sort((a, b) => b.size - a.size) + .slice(0, 5); + if (topAssets.length > 0) { + console.log(`Largest non-JS/CSS assets:\n`); + for (const f of topAssets) { + console.log(` ${fmtKB(f.size).padStart(9)} ${f.rel}`); + } + console.log(''); + } + + console.log( + `Hint: chunks marked ⚠ are ≥200 KB and in the shared 'chunks/' dir. They may be\n` + + `reachable from the shared runtime (loaded eagerly on any cold visit) rather than\n` + + `behind a dynamic import. Inspect the producing module and wrap with await import()\n` + + `if appropriate. Chunks in nodes/ are route-scoped (lazy by route); workers/ are\n` + + `Web-Worker-scoped (lazy until the worker is instantiated).\n` + ); +} + +audit();