` that then branches into two wrapper shapes. This is the structure being restructured; read it before touching it.
+ - `neode-ui/src/composables/useCachedResource.ts` — all 107 lines. Note `refreshIfStale()` (line ~72), `stale()` (line ~71), the `getCurrentScope()` + `onScopeDispose` block (lines ~86-92), and the `if (opts.immediate ?? true) refreshIfStale()` call (line ~94) whose placement the new hook mirrors.
+ - `neode-ui/src/stores/resources.ts` — the backing store; `refresh()` already dedupes concurrent calls per key via its `inflight` map, which is why an extra reactivation-triggered call on first mount is harmless.
+ - `neode-ui/src/views/dashboard/useRouteTransitions.ts` — `TAB_ORDER` (lines 4-15) is the canonical main-tab path list; `getTransitionName()` must keep working unchanged; `isDetailRoute()` is deliberately NOT the classifier used here.
+ - `neode-ui/src/router/index.ts` — the dashboard child routes and their `name` values; confirm the tracer tab's path and that detail routes such as `apps/:id` and `marketplace/:id` are siblings under the same parent.
+ - `neode-ui/src/views/Marketplace.vue` — the tracer tab (unless the findings ranking says otherwise). Read `onMounted` at line ~377, `loadCommunityMarketplace()`, `loadBitcoinPruneStatus()` (fetches `/bitcoin-status`), and the `marketplaceAnimationDone` one-shot flag.
+ - `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for defining a cached resource and for the keep-last-value error handling to mirror.
+ - `neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts` — the in-repo Vitest + `@vue/test-utils` + Pinia mounting pattern to follow for the new tests.
+ - `.planning/phases/02-ui-performance/02-FINDINGS.md` — the ranked fix order that selects the tracer tab, and the measured cause for that tab.
+
+
+ Write the failing tests first, then make them pass.
+
+ **A. Route classifier.** Create `neode-ui/src/views/dashboard/keepAliveRoutes.ts`
+ exporting `KEEP_ALIVE_MAX` (set to 6), `KEEP_ALIVE_PATHS` (a `ReadonlySet`
+ seeded with ONLY the tracer tab's path — plan 02-04 widens it after the lifecycle
+ audit), and `shouldKeepAlive(route: RouteLocationNormalizedLoaded | { path: string })`
+ returning true only for an exact path match. Exact-match, not prefix-match: a
+ prefix match would sweep in `/dashboard/marketplace/:id` and every other secondary
+ screen, which D-04 forbids from the instance cache. Do not derive this from
+ `isDetailRoute()` — that helper only recognises `/apps/` and `/marketplace/` details
+ and misses `cloud/:folderId`, `server/openwrt`, `web5/credentials`, `goals/:goalId`
+ and `app-session/:appId`. Export `TAB_ORDER` from `useRouteTransitions.ts` (it is
+ currently a module-private `const`) so plan 02-04 can widen `KEEP_ALIVE_PATHS` from
+ it without editing that file again.
+
+ Deliberately do not use `` name matching. Every one of the 44
+ routes is an async component (`component: () => import(...)`) and no view in this
+ codebase calls `defineOptions({ name })`, so `include` would depend on name
+ inference through the async wrapper — the failure mode RESEARCH.md flags as
+ assumption A1 (vuejs/core issue 11764). Route-path classification sidesteps it and
+ is also the fix for RESEARCH.md pitfall 7.
+
+ **B. Extract and restructure the KeepAlive host.** Create
+ `neode-ui/src/views/dashboard/DashboardRouterView.vue` holding the nested
+ `` currently inlined in `Dashboard.vue`,
+ taking `mobileTabPaddingTop: number | null` and `needsMobileBackButtonSpace: boolean`
+ as props (both are computed in `Dashboard.vue` today). Replace that inline block in
+ `Dashboard.vue` with ``.
+
+ Four structural invariants govern the new template, and the tests below exist to
+ pin them:
+
+ 1. Nothing between the RouterView slot and `` may carry a binding that
+ changes identity per route. The current `` sits exactly
+ there; if a `
` is nested under it, that div is torn down on every
+ navigation and takes the entire instance cache with it, producing a change that
+ reviews clean and improves nothing. Hoist the wrapper out and drive its
+ appearance from route-derived computed values instead of from a changing key.
+ 2. Composition order is `` outside `` outside
+ ``.
+ 3. The `:key="route.path"` binding belongs on `` itself, never on an
+ ancestor of ``.
+ 4. Both existing wrapper shapes must survive byte-for-byte in their visual result:
+ the chat/mesh branch (`h-full`, plus `dashboard-scroll-panel mobile-scroll-pad
+ mesh-dashboard-panel` for the mesh path, plus `overflow-y-auto` and the
+ `mobileTabPaddingTop + 16` padding when that prop is set, plus `mobile-safe-top`)
+ and the default branch (`absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto
+ mobile-safe-top dashboard-scroll-panel`, plus `mobile-scroll-pad-back` or
+ `mobile-scroll-pad`, the `view-container flex-none` class applied to the rendered
+ component, and the trailing `shrink-0 h-6 md:h-12` spacer div).
+
+ Express the two shapes as computed helpers in the new component (for example
+ `isFullBleedRoute(route)`, `wrapperClass(route)`, `wrapperStyle(route)`) applied to a
+ single stable wrapper element, and render two sibling branches inside it: a
+ `` branch
+ and a plain `` branch for everything else. `getTransitionName(route)` keeps
+ driving both.
+
+ Because the default branch's wrapper is the scroll container and it is now stable
+ across routes, add explicit per-route scroll retention in the new component: keep a
+ `Map` of `scrollTop` by route path, write the outgoing path's value
+ in a `watch` on `route.path` before the new view paints, and restore the incoming
+ path's value on `nextTick` after it does. Without this, a kept-alive tab would inherit
+ the previous tab's scroll offset, which is worse than today's reset-to-top.
+
+ **C. Close the reactivation gap in the hook.** In
+ `neode-ui/src/composables/useCachedResource.ts`, import `onActivated` from `vue` and
+ register `onActivated(() => refreshIfStale())` inside the existing
+ `if (getCurrentScope())` block, alongside `onScopeDispose`. Vue no-ops this hook
+ outside a `` boundary, so it is safe for all eight existing consumers.
+ Without it a kept-alive tab paints instantly forever and never revalidates, because
+ `onScopeDispose` does not fire on deactivate and the `window` focus listener does not
+ fire on an in-SPA tab switch. Add a short comment above it naming why reactivation is
+ a distinct trigger from mount and from focus.
+
+ **D. Register the tracer tab.** Seed `KEEP_ALIVE_PATHS` with exactly the tracer tab's
+ path and nothing else. Its data conversion lands in Task 2; this task proves the
+ instance survives and that the hook revalidates on reactivation, which is the
+ architectural question. Plan 02-04 widens the set after auditing every tab's
+ lifecycle — do not widen it here.
+
+ **E. The tests.** Create
+ `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` mounting
+ `DashboardRouterView` with a `createRouter` on `createMemoryHistory` and two stub
+ route components that each increment a module-level mount counter in `onMounted` and
+ an activation counter in `onActivated`. Assert: navigating to the kept-alive path,
+ away, and back leaves the mount counter at 1 and the activation counter at 2; the
+ same round-trip on a detail path such as `/dashboard/marketplace/abc` leaves that
+ stub's mount counter at 2; and `shouldKeepAlive` returns false for a detail path
+ whose prefix matches an included path.
+
+ Create `neode-ui/src/composables/__tests__/useCachedResource.test.ts` mounting a
+ consumer component inside a real `` with a `vi.fn()` fetcher. Assert:
+ deactivate and reactivate inside the TTL calls the fetcher no additional times;
+ deactivate, advance fake timers past the TTL, reactivate calls it exactly once more;
+ the same hook used outside any `` mounts and fetches without throwing; a
+ rejected refresh leaves `entry.data` at its previous value with `entry.error` set;
+ and `loadState` moves ready to refreshing rather than back to loading.
+
+
+ cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts src/composables/__tests__/useCachedResource.test.ts && npm run type-check
+
+
+ - `npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts` exits 0
+ - `npm run test -- src/composables/__tests__/useCachedResource.test.ts` exits 0
+ - `npm run type-check` exits 0
+ - `npm run test` (full suite) exits 0 — the eight existing `useCachedResource` consumers are unregressed
+ - In the round-trip test the kept-alive stub records exactly 1 mount and 2 activations; the detail-route stub records 2 mounts
+ - `neode-ui/src/views/dashboard/keepAliveRoutes.ts` exports `shouldKeepAlive`, `KEEP_ALIVE_PATHS` and `KEEP_ALIVE_MAX`, and `shouldKeepAlive({ path: '/dashboard/marketplace/abc' })` returns false
+ - `neode-ui/src/composables/useCachedResource.ts` imports `onActivated` from `vue`: `grep -c "onActivated" neode-ui/src/composables/useCachedResource.ts` is at least 2
+ - `neode-ui/src/views/dashboard/DashboardRouterView.vue` contains `KeepAlive` and `shouldKeepAlive`, and `neode-ui/src/views/Dashboard.vue` renders `DashboardRouterView`
+ - `KEEP_ALIVE_PATHS` contains exactly one entry — the tracer tab's path
+ - `npm run build` exits 0 and the built bundle carries the new code: after building, `grep -rl "shouldKeepAlive\|KeepAlive" web/dist/neode-ui/assets | head -1` prints a file (CLAUDE.md warns the frontend build can silently no-op)
+
+ The tracer tab renders from a surviving component instance on revisit, the hook revalidates on reactivation only when stale, a detail route still mounts fresh, and the full Vitest suite is green.
+
+
+
+ Task 2: Subtle refresh indicator and correct per-visit behavior on the tracer tab
+ neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/views/Marketplace.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
+
+ - `neode-ui/src/views/Marketplace.vue` — the tracer tab as left by Task 1; read its header/toolbar markup to find where a refresh affordance belongs, and its `marketplaceAnimationDone` one-shot flag at `onMounted` (line ~377)
+ - `neode-ui/src/components/` — list it and read two or three existing small components to match the house glass/dark styling, spacing and `