docs(02-ui-performance): create phase plan — 8 plans, 5 waves

This commit is contained in:
archipelago 2026-07-30 04:48:31 -04:00
parent abba457e50
commit db18532e12
9 changed files with 2853 additions and 2 deletions

View File

@ -64,7 +64,17 @@ Plans:
2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session
3. Secondary screens open without a blocking full reload; repeat visits are instant
4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device
**Plans**: TBD
**Plans**: 8 plans
Plans:
- [ ] 02-01-PLAN.md — Profile every D-09 surface on archi-dev-box and commit the findings doc (PERF-01)
- [ ] 02-02-PLAN.md — TRACER: KeepAlive host, hook reactivation, app-store tab, refresh indicator (PERF-02)
- [ ] 02-03-PLAN.md — Secondary screens: per-item cache, parallel loads, purge on logout (PERF-03)
- [ ] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02)
- [ ] 02-05-PLAN.md — Mesh: cache the six fetch groups, bound the D3 graph and Leaflet map (PERF-02)
- [ ] 02-06-PLAN.md — Server and Home: cache the uncached fan-out, guarantee wallet freshness (PERF-02)
- [ ] 02-07-PLAN.md — Chat/AIUI: stable embed URL + the two D-14 UX defaults (PERF-02)
- [ ] 02-08-PLAN.md — Dev-pair deploy, on-device re-measure, D-11 pass bar (PERF-01/02/03)
**UI hint**: yes
### Phase 3: Multinode Verification Pass
@ -144,7 +154,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Federation & Mesh Hardening | 0/10 | Planned | - |
| 2. UI Performance | 0/TBD | Not started | - |
| 2. UI Performance | 0/8 | Planned | - |
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
| 5. Registry-Distributed Manifests | 0/TBD | Not started | - |

View File

@ -0,0 +1,368 @@
---
phase: 02-ui-performance
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/e2e/perf/surfaces.ts
- neode-ui/e2e/perf/measure.ts
- neode-ui/e2e/perf/surface-perf.spec.ts
- .planning/phases/02-ui-performance/02-PERF-BASELINE.json
- .planning/phases/02-ui-performance/02-FINDINGS.md
autonomous: true
requirements: [PERF-01]
must_haves:
truths:
- "Every surface in the D-09 starting set (Apps, Marketplace/Discover app store, Mesh, Wallet/send flows, Cloud/Files, Server, Network, Web5, plus AppDetails) has a recorded first-visit and revisit measurement in 02-PERF-BASELINE.json"
- "For each measured surface the findings doc names exactly one primary cause drawn from the closed set: remount storm, serial RPC waterfall, uncached fetch, or already-fast"
- "Each named cause is backed by a number in 02-PERF-BASELINE.json (revisit ms, revisit RPC count, or remount-probe result) — not by code reading alone"
- "02-FINDINGS.md is committed to git before any production source file under neode-ui/src is modified by this phase (D-10)"
- "The harness re-runs on demand and produces a comparable JSON artifact, so the same measurements can be taken again after the fixes land"
- "A surface that could not be measured is recorded in the findings doc as unmeasured with the reason, and is never recorded as already-fast"
- statement: "Running the harness twice against an unchanged build yields the same primary-cause classification for every surface"
verification: backstop
prohibitions:
- "MUST NOT present inferred, code-read, or cherry-picked numbers as measured profiling results, and MUST NOT omit a D-09 surface from the findings doc because it was hard to measure — an unmeasured surface is recorded as unmeasured, never as already-fast"
- "MUST NOT ship performance instrumentation that transmits, logs, or persists node or user activity off-device — profiling stays local to the developer's browser and harness run"
- "MUST NOT commit node-identifying or account-identifying material (onion addresses, DIDs, pubkeys, wallet balances, file names, peer hostnames) into the profiling artifacts"
artifacts:
- path: "neode-ui/e2e/perf/surfaces.ts"
provides: "SURFACES table — one row per D-09 surface with route path, content selector and view-root selector"
exports: ["SURFACES", "type Surface"]
- path: "neode-ui/e2e/perf/measure.ts"
provides: "measureSurface() — first-visit vs revisit timing, RPC request trace, remount probe"
exports: ["measureSurface", "type SurfaceMeasurement", "type RpcCall"]
- path: "neode-ui/e2e/perf/surface-perf.spec.ts"
provides: "Playwright spec that walks every SURFACES row and writes the JSON artifact"
- path: ".planning/phases/02-ui-performance/02-PERF-BASELINE.json"
provides: "Recorded pre-fix measurements for every D-09 surface"
- path: ".planning/phases/02-ui-performance/02-FINDINGS.md"
provides: "D-10 deliverable — surface, measured cause, intended fix, owning plan"
key_links:
- from: "neode-ui/e2e/perf/surface-perf.spec.ts"
to: "neode-ui/e2e/perf/measure.ts"
via: "imports measureSurface and calls it once per SURFACES row"
pattern: "measureSurface"
- from: "neode-ui/e2e/perf/measure.ts"
to: "browser network layer"
via: "page.on('request') / page.on('response') captures RPC POSTs so revisit RPC count is observed, not assumed"
pattern: "page\\.on\\(['\"]request"
- from: ".planning/phases/02-ui-performance/02-FINDINGS.md"
to: ".planning/phases/02-ui-performance/02-PERF-BASELINE.json"
via: "every named cause cites the baseline row it came from"
pattern: "02-PERF-BASELINE"
---
<objective>
Measure the D-09 surfaces on real node hardware, name each one's primary cause from
observed numbers, and commit the findings doc that gates every fix in this phase.
Purpose: PERF-01 and D-10 require that fixes are targeted, not guessed. Nothing under
`neode-ui/src/` changes in this plan — the harness lives entirely in `neode-ui/e2e/perf/`
so the "measure before you fix" ordering is structurally guaranteed rather than merely
promised.
Output: a re-runnable Playwright perf harness, a committed baseline JSON, and
`02-FINDINGS.md` mapping each surface to a measured cause and the plan that fixes it.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@CLAUDE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Build the re-runnable surface perf harness</name>
<files>neode-ui/e2e/perf/surfaces.ts, neode-ui/e2e/perf/measure.ts, neode-ui/e2e/perf/surface-perf.spec.ts</files>
<read_first>
- neode-ui/playwright.config.ts — testDir is `./e2e`, baseURL comes from `ARCHY_BASE_URL` (default `http://192.168.1.228`), single `chromium` project, 60s timeout
- neode-ui/e2e/app-launch.spec.ts — the existing login/navigation flow to reuse verbatim; do not invent a second auth path
- neode-ui/e2e/intro-experience.spec.ts — how the existing specs get past the intro/splash gating so a measurement does not accidentally time the intro animation
- neode-ui/src/router/index.ts — the authoritative route table; every `SURFACES` path must exist here
- neode-ui/src/views/dashboard/useRouteTransitions.ts — `TAB_ORDER` is the canonical main-tab path list
- neode-ui/src/api/rpc-client.ts — the RPC transport, so the request filter matches the real endpoint shape rather than a guess
</read_first>
<action>
Create `neode-ui/e2e/perf/surfaces.ts` exporting `type Surface` and a `SURFACES`
array. One row per D-09 surface, each row carrying: `id`, `label`, `path`,
`kind` (`main-tab` or `secondary`), `contentSelector` (a selector that is only
present once real content has painted, not a skeleton or spinner), and
`rootSelector` (the stable outermost element of the view, used by the remount probe).
Rows required by D-09, using the real route paths from `router/index.ts`:
`/dashboard` (home/wallet figures), `/dashboard/apps`, `/dashboard/marketplace`,
`/dashboard/discover`, `/dashboard/cloud`, `/dashboard/mesh`, `/dashboard/server`,
`/dashboard/web5`, `/dashboard/fleet`, `/dashboard/chat`, plus secondary rows
`/dashboard/apps/:id` (AppDetails), `/dashboard/marketplace/:id`
(MarketplaceAppDetails), `/dashboard/cloud/:folderId` (CloudFolder) and
`/dashboard/server/openwrt` (OpenWrtGateway). Derive each `contentSelector` and
`rootSelector` by reading the corresponding view file; prefer an existing stable
class or a `data-` attribute already present over adding markup to `src/`.
D-09 also names "Wallet / send flows". RESEARCH.md could not locate a `Wallet.vue`.
Locate the real wallet surface first (grep for `SendBitcoinModal`, `loadWeb5Status`,
and wallet balance rendering under `neode-ui/src/views` and
`neode-ui/src/components`), then add a row for wherever wallet figures and the send
entry point actually live. If the wallet surface turns out to be a modal rather than
a route, add a row with a `trigger` field naming the selector that opens it, and
measure open-to-content instead of navigate-to-content.
Create `neode-ui/e2e/perf/measure.ts` exporting `measureSurface(page, surface,
opts)` returning a `SurfaceMeasurement`. It must record, per surface:
`firstVisitMs` (navigate from the dashboard home to the surface, wait for
`contentSelector`), `revisitMs` (navigate away to a fixed neutral tab, then back,
wait for `contentSelector`), `firstVisitRpcCount` and `revisitRpcCount` (POSTs
captured via `page.on('request')`, filtered to the RPC endpoint the rpc-client uses),
`revisitRpcCalls` (an ordered array of `{ method, startedAtMs, durationMs }` so
overlapping vs. sequential call timing is visible in the artifact), `remounted`
(see below), and `error` (a string when the surface could not be measured, with
the other numeric fields left null). Take `runs` samples per surface (default 3)
and record every sample plus the median — never only the best one.
Implement the remount probe without touching `src/`: on the first visit, in the
page context, stamp the element matched by `rootSelector` with a unique value on a
dataset key (for example `perfProbe`); after the away-and-back navigation, read the
same key back. A surviving value means the component instance was reused; a missing
value means the view remounted. Record the raw before/after values in the
measurement so the conclusion is auditable.
Derive the serial-vs-parallel signal from `revisitRpcCalls`: expose a computed
`maxConcurrentRpc` and `rpcWallClockMs` on the measurement so a waterfall (calls
starting one after another, `maxConcurrentRpc` of 1) is distinguishable from an
already-parallel fan-out without re-reading the code.
Create `neode-ui/e2e/perf/surface-perf.spec.ts`: a single Playwright test that logs
in using the flow from `app-launch.spec.ts`, iterates `SURFACES`, calls
`measureSurface` for each, and writes the full result array plus a run header
(`baseUrl`, `takenAt`, `commit` from `git rev-parse --short HEAD`, `runs`) to the
path given by the `ARCHY_PERF_OUT` environment variable, defaulting to
`e2e/test-results/surface-perf.json`. A surface that throws is caught, recorded
with its `error` string, and does not abort the remaining surfaces.
Redaction is part of the harness, not a later cleanup step: the measurement must
record RPC method names and timings only. Do not capture request bodies, response
bodies, page text, or screenshots into the JSON artifact.
</action>
<verify>
<automated>cd neode-ui && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v 'e2e/test-results' ; npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line</automated>
</verify>
<acceptance_criteria>
- `neode-ui/e2e/perf/surfaces.ts` exports `SURFACES` and every entry's `path` appears in `neode-ui/src/router/index.ts`
- `SURFACES` contains at least one row for each of: apps, marketplace, discover, cloud, mesh, server, web5, chat, home, fleet, and the located wallet surface
- `neode-ui/e2e/perf/measure.ts` exports `measureSurface` and the file contains `page.on('request'`
- `SurfaceMeasurement` carries all of `firstVisitMs`, `revisitMs`, `firstVisitRpcCount`, `revisitRpcCount`, `revisitRpcCalls`, `maxConcurrentRpc`, `remounted`, `samples`, `error`
- `npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium` exits 0 and writes a JSON file whose top-level array length equals `SURFACES.length`
- The written JSON contains no request or response body text: `node -e "const r=require('./e2e/test-results/surface-perf.json');process.exit(JSON.stringify(r).match(/onion|did:|xpub|npub/i)?1:0)"` exits 0
- `npx tsc --noEmit` reports no errors originating in `e2e/perf/`
</acceptance_criteria>
<done>The harness runs end to end against a reachable Archipelago node and emits a complete, body-free measurement artifact for every D-09 surface.</done>
</task>
<task type="auto">
<name>Task 2: Record the on-device baseline from archi-dev-box</name>
<precondition>archi-dev-box resolves and its web UI answers over HTTP from this machine (`getent hosts archi-dev-box` returns at least one address and the base URL returns a 200/302 for `/`)</precondition>
<files>.planning/phases/02-ui-performance/02-PERF-BASELINE.json</files>
<read_first>
- neode-ui/e2e/perf/surface-perf.spec.ts — the harness written in Task 1, for its env-var contract
- neode-ui/playwright.config.ts — `ARCHY_BASE_URL` is the target override
- scripts/dev-start.sh — how the :8100 dev preview is started, in case the node's own UI is not directly reachable and the preview must proxy to it
- .planning/phases/02-ui-performance/02-CONTEXT.md — D-11 names archi-dev-box as the verification target and states the pass bar
</read_first>
<action>
Run the Task 1 harness against archi-dev-box with `ARCHY_BASE_URL` pointed at that
node and `ARCHY_PERF_OUT` set to
`../.planning/phases/02-ui-performance/02-PERF-BASELINE.json`, with `runs` at 3.
archi-dev-box resolves to IPv6 addresses in this environment; if the bare hostname
does not connect, try the reachable address form and record the exact base URL used
in the artifact's run header.
If the node's UI cannot be driven directly, fall back to the :8100 dev preview
pointed at archi-dev per the phase's existing dev discipline, and record in the run
header that the measurement was taken through the preview rather than against the
node's own served bundle. Do not silently substitute the local mock backend — a
mock-backend run is not an on-device baseline and must be labelled as such in the
header if it is the only run that succeeds.
Add a `notes` field to the run header recording: the target actually used, the
browser build, whether the node was otherwise idle, and any surface that had to be
skipped and why. Commit the artifact.
Do not edit any file under `neode-ui/src/` in this task. If a surface cannot be
measured because its `contentSelector` never appears, fix the selector in
`neode-ui/e2e/perf/surfaces.ts` and re-run rather than dropping the surface.
</action>
<verify>
<automated>node -e "const r=require('/home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-PERF-BASELINE.json'); const rows=r.results??r; if(!Array.isArray(rows)||rows.length===0)process.exit(1); const bad=rows.filter(x=>x.error==null&&(x.revisitMs==null||x.revisitRpcCount==null)); if(bad.length){console.error('incomplete rows',bad.map(b=>b.id));process.exit(1)} console.log('rows',rows.length)"</automated>
</verify>
<acceptance_criteria>
- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` exists and parses as JSON
- Its run header records `baseUrl`, `takenAt`, `commit`, `runs` and a `notes` string naming the actual target
- Every `SURFACES` row appears in the results, each with either numeric measurements or a non-empty `error` string
- No file under `neode-ui/src/` is modified by this task: `git diff --name-only HEAD -- neode-ui/src | wc -l` prints 0
- The artifact is committed (`git log -1 --name-only` lists `02-PERF-BASELINE.json`)
</acceptance_criteria>
<done>A committed on-device baseline exists covering every D-09 surface, with the measurement target explicitly recorded.</done>
</task>
<task type="auto">
<name>Task 3: Write and commit the D-10 findings doc</name>
<files>.planning/phases/02-ui-performance/02-FINDINGS.md</files>
<read_first>
- .planning/phases/02-ui-performance/02-PERF-BASELINE.json — the numbers every claim must cite
- .planning/phases/02-ui-performance/02-RESEARCH.md — the "Concrete Findings Per Surface" table is a hypothesis set to confirm or overturn, not a substitute for the measurement
- .planning/phases/02-ui-performance/02-CONTEXT.md — D-02 (targeted not blanket), D-10 (doc before fixes), D-12/D-13 (fix scope)
- .planning/phases/02-ui-performance/02-PATTERNS.md — the per-view conversion checklist the intended fixes should align with
</read_first>
<action>
Write `.planning/phases/02-ui-performance/02-FINDINGS.md` with these sections:
1. `## Method` — the target used, the harness path, the run command with its env
vars, the sample count, and what each recorded field means. State plainly which
surfaces were measured on archi-dev-box and which (if any) were not.
2. `## Per-Surface Findings` — a table with columns: Surface, First visit (ms,
median), Revisit (ms, median), Revisit RPC count, Max concurrent RPC, Remounted,
Primary cause, Intended fix, Owning plan. `Primary cause` takes exactly one value
from `remount storm`, `serial RPC waterfall`, `uncached fetch`, `already fast`,
or `unmeasured`. Every non-`unmeasured` row cites the baseline field that
justifies the cause (a remount storm needs `remounted: true`; an uncached fetch
needs a non-zero `revisitRpcCount`; a waterfall needs `maxConcurrentRpc` of 1 with
two or more sequential calls).
3. `## Ranked Fix Order` — the surfaces ordered worst-revisit-first. This ranking
selects the tracer tab for plan 02-02.
4. `## Surfaces Left Alone (D-02)` — every surface classified `already fast`, with
its numbers, so the decision not to convert it is auditable.
5. `## Corrections to Prior Research` — record, with evidence, any place the
measurement overturns `02-RESEARCH.md`. At minimum confirm or refute this
planner's finding that `neode-ui/src/views/ContainerAppDetails.vue` has no
importer and no route entry (run `grep -rn "ContainerAppDetails" neode-ui/src` and
paste the result) — RESEARCH.md names it as the confirmed serial-waterfall fix
target, and if it is unreachable then no plan should spend effort on it and the
real waterfalls must come from the measured `revisitRpcCalls` instead.
6. `## Owning Plans` — map each surface to the plan number that fixes it, using the
plan set for this phase (02-02 tracer/app store, 02-03 secondary screens, 02-04
keep-alive lifecycle, 02-05 Mesh, 02-06 Server and Home, 02-07 Chat/AIUI).
Redact before committing: replace any onion address, DID, pubkey, hostname other
than `archi-dev-box`, wallet figure, or file name that appears in a method name or
note with a short placeholder. Method names and timings stay.
Commit this doc as its own commit, and make that commit the last one in this plan —
it is the gate the rest of the phase depends on.
</action>
<verify>
<automated>test -f /home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-FINDINGS.md && for s in '## Method' '## Per-Surface Findings' '## Ranked Fix Order' '## Surfaces Left Alone' '## Corrections to Prior Research' '## Owning Plans'; do grep -qF "$s" /home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-FINDINGS.md || { echo "missing: $s"; exit 1; }; done; echo OK</automated>
</verify>
<acceptance_criteria>
- `02-FINDINGS.md` contains all six required headings
- Every surface present in `02-PERF-BASELINE.json` appears as a row in the Per-Surface Findings table
- Every row's Primary cause is one of the five allowed values
- The doc references `02-PERF-BASELINE.json` at least once
- `## Corrections to Prior Research` contains the literal output of the `ContainerAppDetails` grep
- `git log --oneline -1 -- .planning/phases/02-ui-performance/02-FINDINGS.md` returns a commit
- `git diff --name-only HEAD~3..HEAD -- neode-ui/src | wc -l` prints 0 — no production source changed in this plan
</acceptance_criteria>
<done>The findings doc is committed, every claim in it traces to a measured number, and the fix order for the rest of the phase is fixed in writing.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| archi-dev-box node → developer workstation | Real node data (RPC method names, timings, and anything else the harness might capture) crosses onto the workstation and into a git-tracked artifact |
| git-tracked planning artifacts → repository history | Anything written into `02-PERF-BASELINE.json` / `02-FINDINGS.md` is permanent and pushed |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-06 | Information Disclosure | `e2e/perf/measure.ts` artifact writer | medium | mitigate | Harness records RPC method names and timings only — no request bodies, response bodies, page text, or screenshots (Task 1); Task 3 adds a redaction pass before commit |
| T-02-07 | Information Disclosure | `02-FINDINGS.md` committed to a pushed repo | medium | mitigate | Explicit redaction step in Task 3 for onion addresses, DIDs, pubkeys, wallet figures, peer hostnames and file names |
| T-02-08 | Spoofing | Playwright login flow reusing dev credentials | low | accept | The harness reuses the existing `e2e/app-launch.spec.ts` auth flow against a developer-owned dev node; no new credential surface is introduced and none are written to the artifact |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope: RESEARCH.md's Package Legitimacy Audit records zero new packages, and every primitive used is Vue core, a JS built-in, or already present. If any task finds it needs a new dependency it stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
New symbols and paths created by Phase 02. Newly-created names below are not pre-existing
API and must not be treated as drift from the current codebase.
**Created by this plan (02-01):**
- `neode-ui/e2e/perf/surfaces.ts``SURFACES`, `type Surface`
- `neode-ui/e2e/perf/measure.ts``measureSurface()`, `type SurfaceMeasurement`, `type RpcCall`
- `neode-ui/e2e/perf/surface-perf.spec.ts`
- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json`
- `.planning/phases/02-ui-performance/02-FINDINGS.md`
- Environment variables consumed: `ARCHY_PERF_OUT` (new), `ARCHY_BASE_URL` (pre-existing in `playwright.config.ts`)
**Created elsewhere in Phase 02 (for cross-plan reference):**
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts``shouldKeepAlive()`, `KEEP_ALIVE_PATHS`
- `neode-ui/src/components/RefreshIndicator.vue`
- `neode-ui/src/composables/__tests__/useCachedResource.test.ts`
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts`
- `RouteMeta.keepAlive``vue-router` module augmentation
- `.planning/phases/02-ui-performance/02-PERF-AFTER.json`
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
Nothing below is silently dropped. Each row is an explicit flagged assumption carried into
execution.
### Edge-coverage probe rows (spec-less fallback — all three came back unclassified/unresolved)
| Requirement | Probe status | Disposition here |
|---|---|---|
| PERF-01 | `unclassified` / `unresolved` — probe could not classify | FLAGGED. Not auto-backstopped. Resolved in substance by the `must_haves.truths` written above (measured-cause coverage, closed cause set, doc-before-fixes ordering, unmeasured-is-not-fast), plus one `verification: backstop` truth for run-to-run classification stability. The probe row itself remains unresolved and is surfaced here for human review. |
| PERF-02 | `unclassified` / `unresolved` | FLAGGED and carried in plans 02-02, 02-04, 02-05, 02-06, 02-07, 02-08. |
| PERF-03 | `unclassified` / `unresolved` | FLAGGED and carried in plans 02-03 and 02-08. |
### Prohibition-probe canon referrals (breadcrumbed, deliberately not minted)
- Injection / XSS via cached-then-rendered payloads is canon — covered by `/gsd-secure-phase` and eslint security plugins; not minted here.
- Generic GDPR / data-retention rules for browser storage are canon — covered by `/gsd-secure-phase`; the privacy prohibitions minted in this phase are product-specific payload classes (wallet, credential, peer-content), not the generic rule.
### Corrections to prior phase artifacts
| ID | Item | Evidence | Impact |
|---|---|---|---|
| FA-B | `neode-ui/src/views/ContainerAppDetails.vue` appears to be dead code — `grep -rn "ContainerAppDetails" neode-ui/src` returns only a self-referential comment inside the file itself, and the file has no entry in `neode-ui/src/router/index.ts` | Verified 2026-07-30 by this planner | RESEARCH.md names it as the *confirmed* serial-waterfall fix target. If dead, no plan should spend effort on it. Task 3 re-runs the grep and records the verdict. |
| FA-F | CONTEXT.md `canonical_refs` names `neode-ui/src/App.vue` as the KeepAlive insertion point | RESEARCH.md Pitfall 2, confirmed by this planner reading `Dashboard.vue:87-118` | Superseded — the real per-tab remount point is the nested `RouterView` in `Dashboard.vue`. Carried into plan 02-02. |
</assumptions_and_flagged_items>
<verification>
- `npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium` exits 0
- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` covers every `SURFACES` row
- `.planning/phases/02-ui-performance/02-FINDINGS.md` has all six required headings and is committed
- `git diff --name-only <plan-start>..HEAD -- neode-ui/src` is empty — the D-10 ordering held
</verification>
<success_criteria>
- Every D-09 surface has a measured first-visit and revisit number taken against archi-dev-box (or an explicitly labelled fallback target)
- Every surface has exactly one primary cause from the closed set, each backed by a cited baseline field
- The findings doc is committed before any `neode-ui/src` change in this phase
- The harness can be re-run later to produce a directly comparable after-artifact
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-01-SUMMARY.md` when done. Include the
per-surface cause table and the ranked fix order verbatim — downstream plans read the
tracer-tab selection from it.
</output>

View File

@ -0,0 +1,442 @@
---
phase: 02-ui-performance
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- neode-ui/src/composables/useCachedResource.ts
- neode-ui/src/composables/__tests__/useCachedResource.test.ts
- neode-ui/src/views/dashboard/keepAliveRoutes.ts
- neode-ui/src/views/dashboard/DashboardRouterView.vue
- neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
- neode-ui/src/views/dashboard/useRouteTransitions.ts
- neode-ui/src/views/Dashboard.vue
- neode-ui/src/components/RefreshIndicator.vue
- neode-ui/src/views/Marketplace.vue
autonomous: false
requirements: [PERF-02]
must_haves:
truths:
- "Switching away from the tracer main tab and back renders its content with no spinner and no blank frame, from the surviving component instance"
- "The tracer tab's component instance is reused across a tab round-trip — it mounts once per session, not once per visit"
- "Returning to the tracer tab within the TTL issues no new RPC for its cached resource"
- "Returning to the tracer tab after the TTL has lapsed issues exactly one background revalidation and keeps the previous content on screen while it runs (D-01)"
- "While that background revalidation is in flight a subtle refresh indicator is visible, driven by loadState === 'refreshing' (D-05)"
- "A failed background refresh leaves the last known content on screen and raises no toast (D-07)"
- "A secondary screen reached from a tab's main page is not instance-cached — it mounts fresh each visit (D-04)"
- "The number of cached view instances is capped, so visiting every main tab does not grow the instance cache without bound (D-03)"
- "Scroll position within a main tab is restored on return rather than reset to the top"
- statement: "The route transition animations that played before the KeepAlive restructure still play afterwards, with the same names for the same navigations"
verification: backstop
prohibitions:
- "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation"
- "MUST NOT persist wallet balances, transaction history, credentials, DIDs, seed or identity material, or peer identity payloads to sessionStorage"
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
artifacts:
- path: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
provides: "The single source of truth for which routes are instance-cached, plus the instance cap"
exports: ["shouldKeepAlive", "KEEP_ALIVE_PATHS", "KEEP_ALIVE_MAX"]
- path: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
provides: "The extracted, testable KeepAlive host — the nested RouterView that actually remounts on tab switch"
- path: "neode-ui/src/components/RefreshIndicator.vue"
provides: "Subtle background-refresh indicator driven by a loadState prop (D-05)"
- path: "neode-ui/src/composables/__tests__/useCachedResource.test.ts"
provides: "Coverage for the onActivated revalidation and the preserved sticky-ready / keep-last-value semantics"
- path: "neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts"
provides: "Proof that an included route's instance survives a round-trip and an excluded route's does not"
key_links:
- from: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
to: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
via: "calls shouldKeepAlive(route) to decide which branch renders the view"
pattern: "shouldKeepAlive"
- from: "neode-ui/src/composables/useCachedResource.ts"
to: "vue onActivated"
via: "reactivation triggers refreshIfStale so a KeepAlive'd tab still background-refreshes"
pattern: "onActivated"
- from: "neode-ui/src/views/Marketplace.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "the tracer tab's catalog and status fetches move onto keyed cached resources"
pattern: "useCachedResource"
- from: "neode-ui/src/views/Dashboard.vue"
to: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
via: "Dashboard renders the extracted host in place of its inline nested RouterView"
pattern: "DashboardRouterView"
---
<objective>
PHASE TRACER. Wire one main tab end to end through every layer this phase touches —
route classification, the KeepAlive host inside `Dashboard.vue`'s nested RouterView, the
`useCachedResource` reactivation gap, the tab's own data fetches, and the subtle refresh
indicator — and prove with a runnable test that the tab renders from cache on revisit
while revalidating in the background.
This is the phase's thin end-to-end slice, sequenced immediately after the D-10 profiling
gate (plan 02-01), which CONTEXT.md locks as a hard prerequisite: no production source may
change before the findings doc is committed. Every later plan in this phase expands
horizontally from the architecture proven here. It is production quality, not a prototype
— the only thing "thin" about it is that exactly one tab is converted.
**Tracer tab selection:** use the highest-ranked slow main tab from `02-FINDINGS.md`
`## Ranked Fix Order`. Default and expected pick: `marketplace`
(`neode-ui/src/views/Marketplace.vue`) — the app store the user reported as the worst
surface, moderate size, and it exercises all three layers (instance cache, data cache,
refresh indicator). If the ranking's top entry is `mesh`, take the next entry instead:
`Mesh.vue` is 2,651 lines with a live D3 force graph and a Leaflet map, which exceeds a
single task's context budget and is planned separately as 02-05. Record the pick and the
reason in the SUMMARY.
Purpose: PERF-02 — main-tab switches render immediately from cached state with background
refresh. Proving the whole path on one tab first means an architectural dead end costs one
commit instead of ten.
Output: a working, instance-cached, stale-while-revalidate main tab; the shared
KeepAlive host and route classifier every other tab will use; the hook fix that makes
background refresh actually fire on revisit; and the tests that pin all of it.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: One main tab survives a tab round-trip and revalidates on return</name>
<reversibility rating="costly">`Dashboard.vue`'s nested RouterView is the single mount point every dashboard view renders through, and `useCachedResource` already has eight consumers — undoing either shape later means touching every view again.</reversibility>
<files>neode-ui/src/composables/useCachedResource.ts, neode-ui/src/composables/__tests__/useCachedResource.test.ts, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/DashboardRouterView.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts, neode-ui/src/views/dashboard/useRouteTransitions.ts, neode-ui/src/views/Dashboard.vue</files>
<read_first>
- `neode-ui/src/views/Dashboard.vue` — read the template around lines 87-118. The nested `<RouterView v-slot="{ Component, route }">` sits inside `<Transition>`, whose child is a `<div :key="route.path" class="view-wrapper">` 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.
</read_first>
<action>
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<string>`
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 `<KeepAlive :include>` 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
`<RouterView v-slot="{ Component, route }">` 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 `<DashboardRouterView :mobile-tab-padding-top="..."
:needs-mobile-back-button-space="..." />`.
Four structural invariants govern the new template, and the tests below exist to
pin them:
1. Nothing between the RouterView slot and `<KeepAlive>` may carry a binding that
changes identity per route. The current `<div :key="route.path">` sits exactly
there; if a `<KeepAlive>` 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 `<Transition>` outside `<KeepAlive>` outside
`<component :is="Component">`.
3. The `:key="route.path"` binding belongs on `<component :is>` itself, never on an
ancestor of `<KeepAlive>`.
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
`<Transition><KeepAlive :max="KEEP_ALIVE_MAX"><component :is="Component"
:key="route.path" v-if="shouldKeepAlive(route)" /></KeepAlive></Transition>` branch
and a plain `<Transition><component :is="Component" :key="route.path"
v-else /></Transition>` 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<string, number>` 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 `<KeepAlive>` 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 `<KeepAlive>` 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 `<KeepAlive>` 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.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts src/composables/__tests__/useCachedResource.test.ts && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Subtle refresh indicator and correct per-visit behavior on the tracer tab</name>
<files>neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/views/Marketplace.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts</files>
<read_first>
- `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 `<script setup lang="ts">` prop-typing conventions
- `neode-ui/src/stores/resources.ts` — the `ResourceLoadState` union (`idle | loading | ready | refreshing | error`) that the indicator's prop is typed against
- `.planning/codebase/CONVENTIONS.md` — component file structure, `defineProps<{}>()` typing, and the "types not enums" rule
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-05 (subtle indicator, no stale-age badges), D-07 (silent keep-last-value), D-08 (persist policy)
</read_first>
<behavior>
- RefreshIndicator renders nothing when `state` is `ready` or `idle`
- RefreshIndicator renders its indicator element when `state` is `refreshing`
- RefreshIndicator renders nothing when `state` is `loading` — a first load is the view's own skeleton's job, not this component's
- The indicator element carries an accessible label and `aria-live="polite"` so a background refresh is announced without stealing focus
- When a background refresh on the tracer tab rejects, the previously rendered content is still in the DOM and no toast function is called
</behavior>
<action>
First put the tracer tab's data on the cache, following the `Cloud.vue` pattern: a
keyed resource per logical dataset, `computed` views over `entry.data` and
`entry.loadState`, and keep-last-value error handling that sets a banner ref rather
than raising a toast (D-07). For `Marketplace.vue` that means the shared app-catalog
fetch behind `loadCommunityMarketplace()` (key `app-catalog`, a long TTL of 300000 ms
— the catalog is near-static, per the D-06 discretion) and the `/bitcoin-status` fetch
behind `loadBitcoinPruneStatus()` (key `bitcoin.prune-status`, the 30000 ms default).
Put the catalog fetch behind a shared key rather than a Marketplace-private one so
`Discover.vue`, which calls the same loader, picks up the same cache entry without its
own conversion in plan 02-04. Decide `persist` explicitly per resource rather than
taking the default: `app-catalog` and `bitcoin.prune-status` are non-sensitive and
small, so both persist. Pass `dedup: true` on the underlying calls.
Then create `neode-ui/src/components/RefreshIndicator.vue`: a small presentational
component taking `state: ResourceLoadState` and an optional `label?: string`. It
renders a compact spinner or shimmer sized to sit inline in a view header — small
enough to read as ambient rather than as a blocking loader. Match the existing
design system: reuse the spinner treatment already present in the codebase (for
example the `chat-loading-spinner` rule in `Chat.vue`'s scoped styles) rather than
inventing a second spinner idiom, and use the same glass/white-alpha palette as its
neighbours. Exact placement and styling are Claude's discretion per CONTEXT.md, but
it must not shift layout when it appears and disappears — reserve its space or
position it absolutely.
Wire it into the tracer tab's header, bound to the tab's primary resource
`loadState`. Do not surface stale-age text or a "last updated" badge — D-05 rules
those out.
Then correct the tracer tab's per-visit behavior now that its instance survives.
`onMounted` fires exactly once for the lifetime of a kept-alive instance, so audit
every side effect in that view and place each one deliberately:
- Genuinely once-per-session setup stays in `onMounted`.
- Anything that should re-run on every tab entry moves to `onActivated`.
- Anything that should stop while the tab is off screen (intervals, subscriptions,
window listeners) gains a matching `onDeactivated` teardown, with `onActivated`
re-arming it.
For `Marketplace.vue` specifically: `marketplaceAnimationDone` is a one-shot intro
flag and stays where it is; the catalog and prune-status loads are now cache-gated
and revalidate through the hook's own `onActivated`, so they need no per-view hook.
Record in the SUMMARY every side effect you moved and every one you deliberately
left in `onMounted`, with the reason — plan 02-04 repeats this audit across the
remaining tabs and needs the precedent.
Confirm the error path matches D-07: a failed background refresh keeps the last
known content on screen and sets the view's existing error banner ref; it must not
call the toast composable. Errors surface on an explicit user-triggered refresh only.
Extend the existing test file with an indicator test: mount `RefreshIndicator` for
each `ResourceLoadState` value and assert the render-nothing / render-something
matrix in the behavior block above.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts && npm run type-check && npm run test</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/components/RefreshIndicator.vue` exists and its props are typed with `defineProps<{ state: ResourceLoadState; label?: string }>()`
- Mounting `RefreshIndicator` with `state: 'refreshing'` renders a non-empty element; with `state: 'ready'`, `'idle'` and `'loading'` it renders nothing
- The rendered indicator element carries `aria-live="polite"` and a non-empty accessible label
- The tracer tab view imports `useCachedResource`, defines the `app-catalog` and `bitcoin.prune-status` keys with explicit `ttlMs` and `persist` values, and no longer calls those loaders from `onMounted` without a cached resource behind them
- The tracer tab view imports and renders `RefreshIndicator` bound to a resource `loadState`
- `npm run test` exits 0 and `npm run type-check` exits 0
- A rejected background refresh in the tracer tab test leaves the prior data rendered and invokes no toast
- The SUMMARY lists each side effect that moved to `onActivated`/`onDeactivated` and each one deliberately left in `onMounted`, with reasons
</acceptance_criteria>
<done>The tracer tab shows a subtle, non-layout-shifting refresh indicator during background revalidation, keeps its content on a failed refresh without a toast, and every one of its side effects is deliberately placed for the kept-alive lifecycle.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Confirm the tracer tab feels instant on the dev preview against archi-dev</name>
<what-built>
The KeepAlive host inside `Dashboard.vue`'s nested RouterView (extracted to
`DashboardRouterView.vue`), a route-path classifier capping the instance cache at 6,
the `onActivated` revalidation fix in `useCachedResource`, the tracer tab's data
moved onto keyed cached resources, and a subtle refresh indicator. Automated proof
already passing: component instance survives a tab round-trip, detail routes still
remount, no refetch inside the TTL, exactly one background refetch after it.
</what-built>
<how-to-verify>
1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview
pointed at archi-dev, per the phase's dev discipline (password `password123`).
2. Open the tracer tab (the app store / Marketplace unless the SUMMARY says
otherwise). Let it finish loading.
3. Switch to another main tab, then switch back. Expected: content appears
immediately — no spinner, no blank frame, no intro animation replay. Search text,
selected category and scroll position are as you left them.
4. Stay on another tab for longer than the TTL, then return. Expected: content is
still there instantly, and the small refresh indicator appears briefly in the
header while the data revalidates behind it. No full-screen loader, no layout jump.
5. Open a secondary screen from that tab (tap an app to reach its detail page), go
back, and open a different app. Expected: the detail screen behaves as before —
this plan deliberately does not instance-cache secondary screens.
6. Confirm the tab transition animation still plays when moving between main tabs,
and that it is the same animation as before this change.
7. Stop the backend (or pull the node's network) and return to the tracer tab after
the TTL. Expected: the previous content stays on screen, no error toast appears.
</how-to-verify>
<resume-signal>Type "approved", or describe what you saw: which step, what happened instead.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| node RPC / HTTP responses → browser cache | Untrusted-until-validated response payloads now live longer, in memory and in sessionStorage |
| browser tab session → sessionStorage | Cached payloads survive in-tab navigation and reload, readable by any script running on the origin |
| authenticated session → cached view instances | A kept-alive component instance holds rendered data across navigations and across a logout |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-01 | Information Disclosure | `useCachedResource` default `persist: true` writing to sessionStorage | high | mitigate | Task 1D requires an explicit per-resource `persist` decision rather than the default. The two tracer-tab resources (`app-catalog`, `bitcoin.prune-status`) are non-sensitive and persist; any resource carrying wallet figures, transaction history, credentials, DIDs or peer identity is memory-only (`persist: false`) |
| T-02-02 | Information Disclosure | Cached entries surviving a logout or identity switch | high | mitigate | Cache keys used here are node-global and non-identity-bearing. Purging the `resources` store and its `resource:` sessionStorage prefix on logout is specified and verified in plan 02-03 Task 3, which owns the identity-scoping work; this plan must not introduce an identity-bearing key before that lands |
| T-02-03 | Denial of Service | `<KeepAlive>` instance cache on low-power fleet nodes | medium | mitigate | `KEEP_ALIVE_MAX` of 6 caps resident instances with LRU eviction (D-03); Task 2 requires intervals and subscriptions to stop on `onDeactivated` so an off-screen tab costs no CPU; on-device memory is verified in plan 02-08 |
| T-02-09 | Tampering | Restructured `Dashboard.vue` render path | low | accept | The change is render-composition only — no auth guard, route guard or data-validation path is touched. `router/index.ts`'s existing navigation guards are unmodified |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope. `<KeepAlive>` is Vue core, `onActivated` is Vue core, and every other primitive already ships in this repo. If a task finds it needs a new dependency it stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Symbols and paths created by this plan — new API, not drift from the existing codebase:
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts``shouldKeepAlive()`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`
- `neode-ui/src/views/dashboard/DashboardRouterView.vue` — props `mobileTabPaddingTop`, `needsMobileBackButtonSpace`; internal helpers `isFullBleedRoute()`, `wrapperClass()`, `wrapperStyle()`
- `neode-ui/src/components/RefreshIndicator.vue` — props `state: ResourceLoadState`, `label?: string`
- `neode-ui/src/composables/__tests__/useCachedResource.test.ts`
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts`
- `TAB_ORDER` — promoted from module-private to an export of `neode-ui/src/views/dashboard/useRouteTransitions.ts`
- Cache keys introduced: `app-catalog`, `bitcoin.prune-status`
Created elsewhere in Phase 02: `neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped. Resolved in substance by this plan's `must_haves.truths`; one truth (transition-animation parity) is carried as a `verification: backstop` marker because it is a perceptual property the unit tests cannot confirm. The probe row itself stays unresolved and is surfaced here for human review.
- **FA-A (correction to `02-PATTERNS.md`):** PATTERNS.md line 43 advises leaving `:key="route.path"` on the outer wrapper `<div>`. That is unsafe once a `<KeepAlive>` is nested beneath it — a keyed ancestor is torn down on every navigation and destroys the instance cache, producing a change that reads correct and improves nothing. Task 1's round-trip mount-count assertion is the resolution.
- **FA-C (RESEARCH assumption A1):** whether Vue 3.5.24 fixes `KeepAlive` `include`/`exclude` name matching for async components is not settled. Resolved by design — this plan never uses `include`/`exclude`; classification is by route path.
- **FA-D (RESEARCH assumption A2):** the `max` cap value. Set to 6 against 10 entries in `TAB_ORDER`, so the long tail evicts while a normal working set stays resident. Not validated on hardware yet; plan 02-08 tunes it against on-device memory.
- **FA-F (correction to `02-CONTEXT.md` canonical_refs):** `App.vue`'s RouterView is not the remount point — it only ever swaps `OnboardingWrapper`, `Dashboard` and `NotFound`. The real point is the nested RouterView in `Dashboard.vue`, extracted here to `DashboardRouterView.vue`.
- **Open:** the scroll-retention `Map` is unbounded in principle (one number per visited path). Path count is bounded by the route table, so this is accepted rather than mitigated.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0 and the new symbols appear in `web/dist/neode-ui/assets`
- The human-verify checkpoint is approved against archi-dev on the :8100 preview
</verification>
<success_criteria>
- One main tab renders instantly from a surviving component instance on revisit, with no spinner and no blank frame
- A stale return fires exactly one background revalidation, visible as a subtle indicator, with content never leaving the screen
- A failed background refresh is silent and non-destructive
- Secondary screens are unaffected — they still mount fresh
- The instance cache is capped, and the shared classifier, host component and hook fix are in place for every later plan to build on
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-02-SUMMARY.md` when done. It MUST record:
the tracer tab actually chosen and why; the final `DashboardRouterView.vue` template
shape; every side effect moved to `onActivated`/`onDeactivated` versus left in
`onMounted`, with reasons; and the `persist` decision made for each new cache key.
Plans 02-04, 02-05, 02-06 and 02-07 read all four from this file.
</output>

View File

@ -0,0 +1,379 @@
---
phase: 02-ui-performance
plan: 03
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- neode-ui/src/stores/resources.ts
- neode-ui/src/stores/auth.ts
- neode-ui/src/stores/__tests__/resourcesClear.test.ts
- neode-ui/src/views/AppDetails.vue
- neode-ui/src/views/MarketplaceAppDetails.vue
- neode-ui/src/views/__tests__/secondaryScreenCache.test.ts
- neode-ui/src/views/CloudFolder.vue
- neode-ui/src/views/server/OpenWrtGateway.vue
autonomous: true
requirements: [PERF-03]
must_haves:
truths:
- "Opening a secondary screen for an item already opened this session paints its content immediately from cache, with no blocking full reload"
- "A repeat open of the same secondary screen inside the TTL issues no new RPC for the cached dataset"
- "A repeat open after the TTL keeps the previous content on screen while exactly one background revalidation runs"
- "Opening secondary screen for item B never renders item A's data — each per-item cache key embeds the item identifier"
- "Secondary screens are not instance-cached: their component mounts fresh on every visit (D-04)"
- "Independent loads inside a secondary screen run concurrently rather than one awaiting the next"
- "Logging out clears every cached resource from memory and from sessionStorage, so the next session starts empty"
- "Large or peer-sourced payloads (file listings, media metadata) are held in memory only and are never written to sessionStorage (D-08)"
- "A destructive action taken on a secondary screen (uninstall, stop, remove) invalidates that screen's cached entry before the screen re-renders"
- statement: "First opens of a never-before-visited secondary screen may still show a loading state; only repeat opens are required to be instant"
verification: backstop
prohibitions:
- "MUST NOT let one item's or one identity's cached data be served under another — every per-item cache key is fully qualified by the item identifier, and the whole cache is cleared on logout"
- "MUST NOT persist peer-sourced content (other nodes' file listings, media metadata) to sessionStorage"
- "MUST NOT display a stale success or health state after a destructive action — such actions invalidate their screen's cache before rendering"
artifacts:
- path: "neode-ui/src/stores/resources.ts"
provides: "clearAll() — drops every cached entry from memory and every resource: snapshot from sessionStorage"
exports: ["clearAll"]
- path: "neode-ui/src/stores/auth.ts"
provides: "logout() purges the resource cache before the session ends"
- path: "neode-ui/src/views/__tests__/secondaryScreenCache.test.ts"
provides: "Fetcher call-count assertions proving cache-on-repeat-open and per-item key isolation"
- path: "neode-ui/src/stores/__tests__/resourcesClear.test.ts"
provides: "Coverage for clearAll and the logout purge"
key_links:
- from: "neode-ui/src/stores/auth.ts"
to: "neode-ui/src/stores/resources.ts"
via: "logout() calls clearAll() so no cached payload outlives the session"
pattern: "clearAll"
- from: "neode-ui/src/views/AppDetails.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "per-item keyed cached resources keyed by the route's app id"
pattern: "useCachedResource"
- from: "neode-ui/src/views/MarketplaceAppDetails.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "per-item keyed cached resource keyed by the route's marketplace app id"
pattern: "useCachedResource"
---
<objective>
Make secondary screens — the screens reached from a tab's main page — open without a
blocking reload and paint instantly on repeat visits, using the existing
stale-while-revalidate hook keyed per item, with no component-instance caching.
Purpose: PERF-03. D-04 is explicit that secondary screens get `useCachedResource` keyed
per item but no `<KeepAlive>` — item counts are unbounded and an instance cache would
bloat. The data cache alone delivers the instant repeat open.
This plan runs in parallel with the tracer (02-02): it touches a disjoint set of files
and it consumes `useCachedResource` exactly as its eight existing callers already do, so
it does not depend on the tracer's architecture landing first. It does own the
cache-lifetime safety work — the logout purge — that every other plan's caching relies on.
Output: a purge-on-logout cache lifecycle, and the findings-named secondary screens
converted to keyed cached resources with their independent loads parallelized.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Cache lifetime — purge every cached resource on logout</name>
<files>neode-ui/src/stores/resources.ts, neode-ui/src/stores/auth.ts, neode-ui/src/stores/__tests__/resourcesClear.test.ts</files>
<read_first>
- `neode-ui/src/stores/resources.ts` — the whole file. Note `SNAPSHOT_PREFIX` (`resource:`), the `entries` reactive Map, the `inflight`, `revalidators` and `invalidateTimers` Maps, and the existing single-key `evict(key)` at line ~156 whose shape `clearAll` mirrors. The store's return object at the end of `defineStore` is what must gain the new export.
- `neode-ui/src/stores/auth.ts` — the whole file. `logout()` is at line ~76 and calls `rpcClient.logout()`. This is the single choke point: `Dashboard.vue`'s `handleLogout`, `views/settings/AccountSection.vue`'s `handleLogout` and `Login.vue` all route through it via `stores/app.ts`'s `logout: auth.logout` re-export.
- `neode-ui/src/stores/app.ts` line ~50 — confirms the re-export, so no additional call site needs editing.
- `neode-ui/src/stores/__tests__/` — list it and read one existing store test for the Pinia `setActivePinia(createPinia())` setup convention.
</read_first>
<behavior>
- After `clearAll()`, `entries.size` is 0
- After `clearAll()`, no sessionStorage key beginning with `resource:` remains, and keys not beginning with that prefix are untouched
- `clearAll()` cancels any pending invalidate timers and drops the in-flight and revalidator maps, so a resolving fetch from the old session cannot repopulate the cache
- `clearAll()` does not throw when sessionStorage is unavailable or throws on access
- `auth.logout()` clears the cache even when the backend `auth.logout` RPC rejects
</behavior>
<action>
Add `clearAll()` to `neode-ui/src/stores/resources.ts` and include it in the store's
returned object alongside the existing `evict`. It must: clear the `entries` Map;
clear the `inflight`, `revalidators` and `invalidateTimers` Maps, calling
`clearTimeout` on each pending timer first; and remove every sessionStorage key
beginning with `SNAPSHOT_PREFIX`. Iterate the sessionStorage keys into an array
before removing, so the live index does not shift mid-loop, and wrap the whole
storage section in the same defensive try/catch the file already uses around
`sessionStorage` access.
In `neode-ui/src/stores/auth.ts`, call `useResourcesStore().clearAll()` from
`logout()`. Place the call so it runs whether or not the `rpcClient.logout()` RPC
succeeds — a failed server-side logout must still leave no cached payload behind
locally. Do not add a second purge call at any other site; `auth.logout()` is the
choke point every logout path already funnels through.
Write `neode-ui/src/stores/__tests__/resourcesClear.test.ts` covering the five
behaviors above. For the sessionStorage assertions, seed both a `resource:`-prefixed
key and an unrelated key and assert only the former is removed. For the auth test,
mock `rpcClient.logout` to reject and assert the cache is still empty afterwards.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/stores/__tests__/resourcesClear.test.ts && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/stores/resources.ts` exports `clearAll` from its store return object
- `neode-ui/src/stores/auth.ts` calls `clearAll` inside `logout()`: `grep -c "clearAll" neode-ui/src/stores/auth.ts` is at least 1
- `npm run test -- src/stores/__tests__/resourcesClear.test.ts` exits 0 with all five behaviors covered
- A test asserts that a sessionStorage key not beginning with `resource:` survives `clearAll()`
- A test asserts the cache is empty after `logout()` when the logout RPC rejects
- `npm run type-check` exits 0
</acceptance_criteria>
<done>No cached resource — in memory or in sessionStorage — outlives a logout, and the guarantee is pinned by tests.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: App detail screens open instantly on repeat visits</name>
<files>neode-ui/src/views/AppDetails.vue, neode-ui/src/views/MarketplaceAppDetails.vue, neode-ui/src/views/__tests__/secondaryScreenCache.test.ts</files>
<read_first>
- `neode-ui/src/views/AppDetails.vue` — the whole file is 386 lines. Read `onMounted` at line ~204 (`loadBitcoinSync(); loadCredentials()` — already fire-and-forget, so not a waterfall), both loader bodies, how the route's `:id` param reaches the component, and the existing error handling.
- `neode-ui/src/views/MarketplaceAppDetails.vue` — 700 lines. Grep for `onMounted` (line ~525), `rpcClient`, `fetch(` and `await` first, then read only the loader region and the `onMounted` block. Do not read the whole file.
- `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for a cached-resource definition and for the keep-last-value error handling to mirror.
- `neode-ui/src/composables/useCachedResource.ts` — the options contract (`key`, `fetcher`, `ttlMs`, `persist`, `revalidateOnFocus`, `immediate`) and the returned `entry` / `data` / `loadState` / `error` / `refresh` / `invalidate` surface.
- `neode-ui/src/api/rpc-client.ts` — the `dedup: true` option (line ~16, applied at line ~95) to pass on every newly-parallelized call so concurrent identical calls collapse.
- `neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts` — the Vitest + `@vue/test-utils` + Pinia + `vi.mock('@/api/rpc-client')` pattern to follow.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the measured revisit RPC count and primary cause for each of these two screens; if the findings classify one as already fast, leave it alone per D-02 and record that in the SUMMARY.
</read_first>
<behavior>
- Mounting AppDetails for app id `alpha`, unmounting, and remounting for `alpha` inside the TTL calls each fetcher exactly once in total
- Mounting AppDetails for `alpha` then for `beta` calls each fetcher twice in total and renders `beta`'s data, never `alpha`'s
- Remounting for `alpha` after the TTL lapses calls the fetcher once more while the previously cached data is already rendered on the first frame
- A rejected refresh leaves the previously rendered data in the DOM and sets the view's error ref
- The independent loads in a single mount are issued concurrently, not one after the other
</behavior>
<action>
Convert both app-detail screens to keyed cached resources, one resource per logical
dataset, following the `Cloud.vue` pattern.
Cache keys embed the item identifier so two items can never collide: use
`app-details:${appId}` shaped keys — for `AppDetails.vue` that means
`app-details:bitcoin-sync:${appId}` and `app-details:credentials:${appId}`, and for
`MarketplaceAppDetails.vue` a key of the same shape built from its own route param.
The key must be computed from the current route param at hook-call time, and the
component must re-key when the param changes (these screens are not instance-cached,
so a param change normally remounts them — confirm that by reading how the route is
declared, and if the router reuses the instance across an id change, drive the
resource through a `watch` on the id that calls `refresh()` against the new key).
Set `ttlMs` explicitly per resource rather than taking the default. Credentials and
install/health state move fast enough to warrant the 30000 ms default; near-static
catalog-shaped metadata can take a longer value. Set `persist` explicitly too:
anything carrying credential material, DIDs, wallet figures or transaction history
is `persist: false` and stays memory-only, per D-08 and the privacy prohibition in
this plan's `must_haves`. Record each key's TTL and persist choice in the SUMMARY.
Pass `dedup: true` on the underlying `rpcClient.call` for each fetcher so two mounted
consumers of the same method collapse into one request.
Where a screen awaits independent loads sequentially, replace the chain with a single
`await Promise.allSettled([...])``allSettled` rather than `all` so one failing
load does not suppress the others, matching the existing per-loader error handling
where each loader owns its own loading ref. This is D-13's client-side fix: waterfalls
are removed by parallelizing plus rpc-client dedup, and a new aggregate endpoint is
reserved for a screen that genuinely needs three or more dependent calls. If one of
these screens turns out to need such an endpoint, D-12 bounds it: additive only, a new
handler alongside the existing ones, no refactor of an existing handler and nothing
touching the orchestrator — and it stops for a checkpoint before any `core/` change,
since no backend work is otherwise in this plan's scope. Verify independence before
parallelizing:
a load that consumes another's result stays sequential. `AppDetails.vue`'s
`onMounted` already fires both loaders without awaiting them, so it is already
effectively parallel — do not "fix" it into something slower, and say so in the
SUMMARY.
Error handling follows D-07: a failed background refresh keeps the last known value
on screen and sets the view's existing error ref for a banner. No toast.
Invalidate before re-render after a destructive action: wherever these screens
trigger an uninstall, stop, or removal, call the affected resource's `invalidate()`
(or `refresh()`) as part of the action's completion path, so the screen cannot show a
stale healthy state for something that no longer exists.
Create `neode-ui/src/views/__tests__/secondaryScreenCache.test.ts` covering the five
behaviors above with `vi.fn()` fetchers and explicit call-count assertions. Use fake
timers to cross the TTL boundary. The per-item isolation test is the important one —
assert on rendered content, not only on call counts.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/secondaryScreenCache.test.ts && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/AppDetails.vue` and `neode-ui/src/views/MarketplaceAppDetails.vue` each import and call `useCachedResource`
- Every cache key introduced contains the route item id — a test asserts that mounting for `alpha` then `beta` produces two distinct keys and renders `beta`'s data
- `npm run test -- src/views/__tests__/secondaryScreenCache.test.ts` exits 0 with all five behaviors covered
- A repeat mount inside the TTL records exactly one total fetcher call per resource
- A repeat mount after the TTL records exactly two, with the cached data present on the first rendered frame
- Every fetcher passes `dedup: true` to `rpcClient.call`
- Every resource carrying credentials, DIDs, wallet figures or transaction history is declared `persist: false`
- `npm run test` (full suite) exits 0 and `npm run type-check` exits 0
- The SUMMARY lists every key with its TTL, its persist choice, and the reason
</acceptance_criteria>
<done>Both app-detail screens paint from cache on a repeat open, never cross item data, revalidate exactly once when stale, and hold nothing sensitive in sessionStorage.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: The remaining findings-named secondary screens</name>
<files>neode-ui/src/views/CloudFolder.vue, neode-ui/src/views/server/OpenWrtGateway.vue, neode-ui/src/views/__tests__/secondaryScreenCache.test.ts</files>
<read_first>
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the authoritative list. Only screens this doc classifies as `remount storm`, `serial RPC waterfall` or `uncached fetch` are in scope; anything it classifies `already fast` is left alone per D-02.
- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` — the `revisitRpcCalls` array per screen, which shows whether calls overlapped or ran one after another.
- `neode-ui/src/views/CloudFolder.vue` — it has no `onMounted`; data arrives through `cloudStore` and two `watch` blocks (line ~188 on `cloudStore.currentPath`, line ~313 on `[useNativeUI, section, routeFolderPath]`). Read both watches and how `cloudStore` loads before deciding whether the cache belongs in the view or behind the store's loader.
- `neode-ui/src/views/server/OpenWrtGateway.vue` — 909 lines; grep for `onMounted` (line ~374, `onMounted(() => load())`) and `load(` first, then read only the `load()` implementation and the note at line ~204 about reconnecting.
- `neode-ui/src/stores/cloud.ts` — if the cache belongs behind the store loader rather than in `CloudFolder.vue`, this is where it goes; read its loading actions first.
- `neode-ui/src/views/AppDetails.vue` as left by Task 2 — the in-plan precedent for key shape, TTL and persist choices.
</read_first>
<action>
Convert the remaining secondary screens that `02-FINDINGS.md` names, in the ranked
order the findings give, applying the same treatment established in Task 2: a keyed
cached resource per logical dataset with the item identifier in the key, an explicit
TTL, an explicit `persist` decision, `dedup: true` on the underlying call, keep-last-
value error handling with no toast, and `invalidate()` on any destructive action.
Candidate set from the route table, gated on what the findings actually name:
`cloud/:folderId` (`CloudFolder.vue`), `server/openwrt`
(`views/server/OpenWrtGateway.vue`), `cloud/peers/:peerId?` (`PeerFiles.vue`),
`apps/lnd/channels` (`views/apps/LightningChannels.vue`), `goals/:goalId`
(`GoalDetail.vue`) and `app-session/:appId` (`AppSession.vue`). `PeerFiles.vue`,
`Credentials.vue`, `Federation.vue` and `Monitoring.vue` already consume
`useCachedResource`; for those, verify the key embeds the item id and that the persist
choice is right, and change nothing else.
Two payload classes are memory-only regardless of what the findings say: file
listings and media metadata (large, per D-08) and any peer-sourced content — another
node's file listing or media index must not be written to this node's sessionStorage.
Declare `persist: false` for both and note it in the SUMMARY.
For `CloudFolder.vue`, decide where the cache belongs before writing code. Its data
flows through `cloudStore` and two watches, not through a mount hook. If several
views share the same store loader, put the cached resource behind the store action so
every consumer benefits, rather than wrapping the view's own reads and leaving the
store uncached. Record the decision and its reason in the SUMMARY.
Extend `neode-ui/src/views/__tests__/secondaryScreenCache.test.ts` with a repeat-open
call-count assertion for each screen converted here.
Scope guard: if `02-FINDINGS.md` names more than the four screens this plan's
`files_modified` covers, convert them in ranked order until the plan's context budget
is reached, then stop and report the remainder to the orchestrator as an unplanned-item
gap with the surface names and their measured causes. Do not silently skip a named
screen and do not quietly narrow the findings list.
`neode-ui/src/views/ContainerAppDetails.vue` is out of scope. `02-FINDINGS.md`
`## Corrections to Prior Research` records whether it has any importer or route entry;
if it has none, it is unreachable code and converting it would deliver nothing, even
though `02-RESEARCH.md` names it as a confirmed waterfall. Do not spend effort on it,
and do not delete it in this plan.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/secondaryScreenCache.test.ts && npm run test && npm run type-check</automated>
</verify>
<acceptance_criteria>
- Every secondary screen that `02-FINDINGS.md` classifies as slow either appears in this plan's converted set or is reported to the orchestrator as an unplanned-item gap — none is silently skipped
- Each converted screen imports `useCachedResource` and its keys embed the route item id
- Each converted screen has a repeat-open call-count assertion in `secondaryScreenCache.test.ts`
- File-listing and media-metadata resources are declared `persist: false`
- No file named `ContainerAppDetails.vue` appears in this plan's diff: `git diff --name-only HEAD -- neode-ui/src/views/ContainerAppDetails.vue | wc -l` prints 0
- `npm run test` exits 0 and `npm run type-check` exits 0
- `npm run build` exits 0 and the new cache keys appear in the built bundle: `grep -rl "app-details:" web/dist/neode-ui/assets | head -1` prints a file
</acceptance_criteria>
<done>Every secondary screen the profiling pass named as slow opens from cache on a repeat visit, with per-item keys, no sensitive or peer-sourced payload in sessionStorage, and a call-count test pinning each one.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| peer node → this node's browser storage | Peer-sourced file listings and media metadata cross from another operator's node into local storage |
| authenticated session → sessionStorage | Cached per-item payloads survive navigation and reload within the browser tab |
| item A's cache entry → item B's render | A key-construction mistake serves one item's data under another's screen |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-02 | Information Disclosure | Cached entries surviving a logout or identity switch | high | mitigate | Task 1 adds `resources.clearAll()` and calls it from `auth.logout()` on both the success and failure paths, dropping memory entries and every `resource:` sessionStorage snapshot |
| T-02-01 | Information Disclosure | `useCachedResource` default `persist: true` on credential, DID and wallet payloads | high | mitigate | Tasks 2 and 3 require an explicit per-resource `persist` decision; credential material, DIDs, wallet figures and transaction history are `persist: false` (memory-only) |
| T-02-10 | Information Disclosure | Peer-sourced content written to local sessionStorage | high | mitigate | Task 3 declares file listings and media metadata `persist: false` unconditionally, independent of the findings classification |
| T-02-11 | Spoofing | Per-item cache key collision serving item A's data under item B | medium | mitigate | Every key embeds the route item id; Task 2's per-item isolation test asserts on rendered content, not only on fetcher call counts |
| T-02-12 | Tampering | A stale cached entry masking the result of a destructive action | medium | mitigate | Tasks 2 and 3 require `invalidate()` on the completion path of every uninstall, stop or removal action on a converted screen |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope; `Promise.allSettled` is a language built-in and `useCachedResource` already ships in this repo. A task that finds it needs a new dependency stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Symbols and paths created by this plan — new API, not drift from the existing codebase:
- `neode-ui/src/stores/resources.ts` — new export `clearAll()`
- `neode-ui/src/stores/__tests__/resourcesClear.test.ts`
- `neode-ui/src/views/__tests__/secondaryScreenCache.test.ts`
- Cache-key family introduced: `app-details:<dataset>:<appId>` and the same shape for the other converted secondary screens
Created elsewhere in Phase 02: `neode-ui/src/views/dashboard/keepAliveRoutes.ts`
(`shouldKeepAlive`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`),
`neode-ui/src/views/dashboard/DashboardRouterView.vue`,
`neode-ui/src/components/RefreshIndicator.vue`,
`neode-ui/src/composables/__tests__/useCachedResource.test.ts`,
`neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts`,
`neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-03 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped. Resolved in substance by this plan's `must_haves.truths`; the first-visit-may-still-load boundary is carried as a `verification: backstop` marker because CONTEXT.md D-11 states it as an allowance rather than as an assertable check. The probe row itself stays unresolved and is surfaced here for human review.
- **FA-B (correction to `02-RESEARCH.md`):** `neode-ui/src/views/ContainerAppDetails.vue` appears to be dead code — `grep -rn "ContainerAppDetails" neode-ui/src` returns only a self-referential comment inside the file, and it has no entry in `neode-ui/src/router/index.ts` (verified 2026-07-30). RESEARCH.md names it as the phase's confirmed serial-waterfall fix target. Plan 02-01 Task 3 re-runs the grep and records the verdict; this plan excludes the file either way and sources its waterfall targets from measured `revisitRpcCalls` instead.
- **Open:** `CloudFolder.vue` loads through `cloudStore` and two watches rather than a mount hook, so whether the cache belongs in the view or behind the store action is decided during Task 3 and recorded in the SUMMARY.
- **Open:** whether the router reuses a detail component instance across an id change on these routes is not settled from the route table alone. Task 2 requires it to be confirmed by reading the route declaration, with a `watch`-driven re-key as the fallback.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0 and the new cache keys appear in `web/dist/neode-ui/assets`
- Every secondary screen named slow by `02-FINDINGS.md` is either converted here or reported as an unplanned-item gap
</verification>
<success_criteria>
- Repeat opens of a secondary screen paint from cache with no blocking reload and no new RPC inside the TTL
- Per-item keys prevent any cross-item data bleed, proven by a rendered-content assertion
- Secondary screens still mount fresh — nothing here instance-caches them
- Logout leaves no cached payload in memory or in sessionStorage
- No sensitive or peer-sourced payload is written to sessionStorage
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-03-SUMMARY.md` when done. It MUST record:
every cache key introduced with its TTL and persist choice and the reason; which screens
the findings named and which of those were converted here versus reported as a gap; the
`CloudFolder.vue` cache-placement decision; and the verdict on whether
`ContainerAppDetails.vue` is reachable.
</output>

View File

@ -0,0 +1,369 @@
---
phase: 02-ui-performance
plan: 04
type: execute
wave: 3
depends_on: ["02-02"]
files_modified:
- neode-ui/src/views/dashboard/keepAliveRoutes.ts
- neode-ui/src/views/Home.vue
- neode-ui/src/views/web5/Web5.vue
- neode-ui/src/views/Chat.vue
- neode-ui/src/views/Cloud.vue
- neode-ui/src/views/Server.vue
- neode-ui/src/views/Mesh.vue
- neode-ui/src/views/Apps.vue
- neode-ui/src/views/Discover.vue
- neode-ui/src/views/Fleet.vue
- neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
autonomous: false
requirements: [PERF-02]
must_haves:
truths:
- "Every main tab the profiling pass showed remounting on revisit is instance-cached, and switching to it and back shows its previous content immediately"
- "A polling interval, websocket subscription or window listener started by a main tab stops while that tab is off screen and restarts when the tab is re-entered"
- "Returning to a tab that polls live data re-reads that data immediately on re-entry rather than waiting out the poll interval"
- "A one-shot intro or animation flag still fires exactly once per session and does not replay on every tab revisit"
- "A connection-timeout timer that only makes sense on a fresh entry is re-armed on re-entry, not left armed from the first visit"
- "Visiting all main tabs in sequence leaves at most KEEP_ALIVE_MAX view instances resident — the least recently used tab is evicted"
- "Main tabs classified already fast by the profiling pass, with no measured remount cost, are left unregistered and the reason is recorded (D-02)"
- "No secondary screen is instance-cached as a side effect of widening the registration set (D-04)"
- statement: "Off-screen tabs consume no measurable CPU from their own timers while deactivated"
verification: backstop
prohibitions:
- "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation"
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
artifacts:
- path: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
provides: "KEEP_ALIVE_PATHS widened from the tracer's single path to the full audited main-tab set"
exports: ["shouldKeepAlive", "KEEP_ALIVE_PATHS", "KEEP_ALIVE_MAX"]
- path: "neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts"
provides: "Assertions that deactivation stops timers and subscriptions and reactivation restarts them and refreshes live data"
key_links:
- from: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
to: "neode-ui/src/views/dashboard/useRouteTransitions.ts"
via: "KEEP_ALIVE_PATHS is built from the exported TAB_ORDER main-tab list, not from isDetailRoute"
pattern: "TAB_ORDER"
- from: "neode-ui/src/views/Home.vue"
to: "vue onActivated / onDeactivated"
via: "poll intervals and the websocket subscription are torn down on deactivate and re-armed with an immediate refresh on activate"
pattern: "onDeactivated"
- from: "neode-ui/src/views/Chat.vue"
to: "vue onActivated / onDeactivated"
via: "the window message listener and ContextBroker follow activation rather than mount/unmount"
pattern: "onDeactivated"
---
<objective>
Turn instance caching on for every main tab the profiling pass showed remounting, and make
every main tab correct under that lifecycle first.
Purpose: PERF-02. The tracer (02-02) proved the architecture on one tab and deliberately
left `KEEP_ALIVE_PATHS` seeded with only that tab. Widening it is not a one-line config
change: once a view's instance survives, `onMounted` fires exactly once for the session
and `onBeforeUnmount` never fires on tab-away. Every polling interval, websocket
subscription and window listener a main tab starts would otherwise run forever for every
tab ever visited — a CPU and memory drain on the low-power fleet hardware D-03 is
explicitly protecting — and every per-visit refresh would silently stop happening. That
is why registration and the lifecycle audit ship together, in one plan, rather than
registration landing early and correctness catching up later.
Output: the full audited main-tab registration set, and every main-tab view's side
effects deliberately placed for an activate/deactivate lifecycle.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/phases/02-ui-performance/02-02-SUMMARY.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Timers, subscriptions and listeners follow activation, not mount</name>
<files>neode-ui/src/views/Home.vue, neode-ui/src/views/web5/Web5.vue, neode-ui/src/views/Chat.vue, neode-ui/src/views/Cloud.vue, neode-ui/src/views/Server.vue, neode-ui/src/views/Mesh.vue, neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts</files>
<read_first>
- `.planning/phases/02-ui-performance/02-02-SUMMARY.md` — the tracer tab's recorded side-effect placement decisions; this task repeats that audit across the remaining tabs and must stay consistent with the precedent set there.
- `neode-ui/src/views/Home.vue` lines 293 and 524-560 — `onMounted` starts `systemStatsInterval` (10s `loadSystemStats`), `walletRefreshInterval` (30s `loadWeb5Status`), a `wsClient.subscribe` returning `unsubscribeWs`, a `wsWalletDebounce` timeout, and calls `hydrateWalletSnapshot()`. Read the matching `onBeforeUnmount` teardown too.
- `neode-ui/src/views/web5/Web5.vue` lines 93-96, 140, 293, 337 and 371 — two `useCachedResource` resources already exist here plus an `onMounted` and an `onUnmounted`; read all of them.
- `neode-ui/src/views/Chat.vue` lines 61-125 — `onMounted` adds a `window` `message` listener and starts a `ContextBroker`; `onBeforeUnmount` removes and stops them.
- `neode-ui/src/views/Cloud.vue` — grep for `onMounted`, `onBeforeUnmount`, `onUnmounted`, `setInterval` and `subscribe` first, then read only those regions. The file is 1029 lines; do not read it whole.
- `neode-ui/src/views/Server.vue` — grep for the same five tokens; its `onMounted` at line ~831 fires seven independent loads. Read only that region and any teardown.
- `neode-ui/src/views/Mesh.vue` — grep for the same five tokens. The file is 2651 lines; read only the lifecycle regions. Its `onMounted` already does `await Promise.all([...])` across six fetch groups and must stay parallel.
- `.planning/phases/02-ui-performance/02-RESEARCH.md` pitfall 4 and pitfall 6 — the failure modes this task exists to prevent.
</read_first>
<behavior>
- Deactivating a view that owns a polling interval clears that interval; the poll callback is not invoked again while deactivated
- Reactivating that view restarts the interval and immediately invokes its loader once, so the first frame after re-entry is not interval-stale
- Deactivating a view that holds a websocket subscription unsubscribes it; reactivating re-subscribes exactly once, never twice
- Deactivating a view that added a `window` event listener removes it; reactivating adds it back exactly once
- Unmounting a view (rather than deactivating it) still tears everything down, so a non-cached mount path is unregressed
- Two consecutive activations without an intervening deactivation do not double-arm any timer, subscription or listener
</behavior>
<action>
For each view listed in `files`, classify every side effect its lifecycle hooks start
into exactly one of three buckets and place it accordingly:
- **Once per session** — stays in `onMounted`, unchanged. Example shape: a one-time
hydration from a stored snapshot.
- **Every entry** — moves to `onActivated`, and the `onMounted` call is removed so it
is not run twice on the first visit.
- **Only while visible** — started in `onActivated` and stopped in `onDeactivated`,
with the existing `onBeforeUnmount` / `onUnmounted` teardown left in place so the
non-cached path still cleans up.
Make every start idempotent: before arming a timer, clear any existing handle; before
subscribing, drop any existing unsubscribe function; before adding a listener, remove
it. Vue fires `onActivated` on first mount as well as on every reactivation, so a
non-idempotent start would double-arm on the first visit.
Concrete placements this task must make:
`Home.vue``hydrateWalletSnapshot()` is once-per-session and stays in `onMounted`.
`systemStatsInterval` and `walletRefreshInterval` are only-while-visible: clear both
in `onDeactivated`, re-arm both in `onActivated`. The `wsClient.subscribe` handle
(`unsubscribeWs`) and the `wsWalletDebounce` timeout are only-while-visible too. On
re-entry, `onActivated` must call `loadSystemStats()` and `loadWeb5Status()` once
immediately rather than waiting out the 10s and 30s intervals — a wallet balance is a
liveness-critical figure and must never render from a paused poll without an
immediate revalidation behind it.
`Chat.vue` — the `window` `message` listener and the `ContextBroker` are
only-while-visible. Move both to `onActivated` / `onDeactivated`, keeping the existing
`onBeforeUnmount` teardown. Note that `aiuiConnected` is set by a `ready` message from
the iframe: once the iframe survives deactivation, that message will not be re-sent on
re-entry, so `aiuiConnected` must not be reset on deactivate.
`Web5.vue`, `Cloud.vue`, `Server.vue`, `Mesh.vue` — apply the same three-bucket
classification to whatever their greps turn up. Do not restructure their fetch
orchestration in this task: `Mesh.vue`'s `Promise.all` fan-out and `Server.vue`'s
seven fire-and-forget loads are already concurrent, and converting them to cached
resources is plans 02-05 and 02-06. This task only relocates lifecycle side effects.
Write `neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts` covering the
six behaviors above against a small consumer component built with the same
activate/deactivate idiom, plus at least one assertion against a real converted view —
mount it inside a `<KeepAlive>`, deactivate, advance fake timers past its poll
interval, and assert its loader was not called while off screen and was called once on
reactivation.
Record in the SUMMARY, per view, every side effect and the bucket it was placed in.
Plan 02-08's on-device pass reads this table when checking for CPU drain.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveLifecycle.test.ts && npm run test && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/Home.vue` and `neode-ui/src/views/Chat.vue` each reference `onDeactivated`: `grep -c "onDeactivated" neode-ui/src/views/Home.vue` and the same for `Chat.vue` are each at least 1
- `neode-ui/src/views/Home.vue` calls its stats and wallet loaders from `onActivated` so re-entry does not wait out the poll interval
- `npm run test -- src/views/dashboard/__tests__/keepAliveLifecycle.test.ts` exits 0 with all six behaviors covered
- A test asserts a paused interval's callback is not invoked while the view is deactivated
- A test asserts two consecutive activations do not double-arm a timer, subscription or listener
- `npm run test` (full suite) exits 0 and `npm run type-check` exits 0
- `Mesh.vue`'s `onMounted` still awaits a `Promise.all` and `Server.vue` still issues its loads without awaiting them sequentially — neither fetch fan-out was serialized by this task
- The SUMMARY contains a per-view table of every side effect and its assigned bucket
</acceptance_criteria>
<done>Every main-tab side effect is deliberately placed for an activate/deactivate lifecycle, off-screen tabs run no timers or subscriptions, and re-entering a live-data tab refreshes it immediately.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: One-shot flags, entry timers, and widening the registration set</name>
<files>neode-ui/src/views/Apps.vue, neode-ui/src/views/Discover.vue, neode-ui/src/views/Fleet.vue, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts</files>
<read_first>
- `neode-ui/src/views/Apps.vue` lines 383 and 544-560 — `onMounted` sets `appsAnimationDone = true` and, when the store is not connected, arms a 15s `connectionTimer` that raises `connectionError`; `onBeforeUnmount` clears the timer.
- `neode-ui/src/views/Discover.vue` lines 232 and 598-605 — `onMounted` sets `discoverAnimationDone` and calls `loadCommunityMarketplace()` (guarded on an empty list) and `loadBitcoinPruneStatus()`.
- `neode-ui/src/views/Fleet.vue` — 154 lines with no lifecycle hook found by grep; confirm before changing anything.
- `neode-ui/src/views/Marketplace.vue` as left by plan 02-02 — the tracer already moved its catalog and prune-status fetches onto the shared `app-catalog` and `bitcoin.prune-status` cache keys; `Discover.vue` calls the same loader and should pick up the same entries without a second conversion.
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` — as created by 02-02, seeded with the tracer path only.
- `neode-ui/src/views/dashboard/useRouteTransitions.ts``TAB_ORDER` (now exported by 02-02) is the canonical main-tab path list: `/dashboard`, `/dashboard/apps`, `/dashboard/marketplace`, `/dashboard/cloud`, `/dashboard/mesh`, `/dashboard/server`, `/dashboard/web5`, `/dashboard/fleet`, `/dashboard/chat`, `/dashboard/settings`.
- `neode-ui/src/router/index.ts` — confirm `/dashboard/discover` is a real route (`name: 'discover'`, `Discover.vue`) that `TAB_ORDER` does not list, and that `/dashboard/monitoring` is reached from Web5 rather than from the tab bar.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the per-surface `Remounted` column and primary cause, which decide which tabs get registered.
</read_first>
<behavior>
- A one-shot intro flag set on first entry is still set exactly once across three visits to the same tab
- A connection-timeout timer that guards a fresh entry is re-armed on each re-entry and cleared on each exit, so it never fires against a stale visit
- `shouldKeepAlive` returns true for every registered main-tab path and false for every detail path, including detail paths whose prefix matches a registered path
- Visiting more distinct registered tabs than `KEEP_ALIVE_MAX` leaves exactly `KEEP_ALIVE_MAX` instances resident, and the least recently used one has been unmounted
</behavior>
<action>
First, finish the lifecycle audit for the remaining main tabs.
`Apps.vue`: `appsAnimationDone` is a one-shot intro flag and stays in `onMounted`.
The 15s `connectionTimer` is an entry-scoped guard — under a surviving instance it
would be armed once on the first visit and never again, so its diagnosis of "unable to
connect" would go stale. Move arming to `onActivated` (clearing any prior handle
first) and clearing to `onDeactivated`, keeping the existing `onBeforeUnmount` clear.
`Discover.vue`: `discoverAnimationDone` is a one-shot flag and stays put. Its
`loadCommunityMarketplace()` and `loadBitcoinPruneStatus()` calls now resolve against
the shared cache keys the tracer introduced; confirm by reading `Marketplace.vue` as
the tracer left it, and route `Discover.vue` through the same cached resources rather
than duplicating the fetch. Wire the shared resource's `loadState` to
`RefreshIndicator` in this view's header the same way the tracer did — the subtle
in-header signal D-05 specifies, never a stale-age badge.
`Fleet.vue`: confirm it has no lifecycle side effects before changing anything. If the
grep finds none, change nothing and record that.
Then widen the registration set. Build `KEEP_ALIVE_PATHS` in
`neode-ui/src/views/dashboard/keepAliveRoutes.ts` from the imported `TAB_ORDER` plus
`/dashboard/discover`, minus any path that `02-FINDINGS.md` classifies `already fast`
with a `Remounted` value of false. A tab with no measured remount cost gains nothing
from an instance cache and D-02 says to leave already-fast views alone; a tab with
`Remounted: true` has a real cost to remove and is registered. Keep the source list
derived from `TAB_ORDER` rather than restating ten literal paths, so a future tab
addition does not silently miss registration. Record in the SUMMARY exactly which
paths ended up in the set and which were excluded with their measured reason.
Do not widen the match from exact-path to prefix-path. Every secondary screen in the
route table sits under a main tab's path prefix — `/dashboard/apps/:id`,
`/dashboard/marketplace/:id`, `/dashboard/cloud/:folderId`, `/dashboard/server/openwrt`,
`/dashboard/web5/credentials`, `/dashboard/settings/update` — and a prefix match would
instance-cache all of them, which D-04 rules out.
`KEEP_ALIVE_MAX` stays at 6 against roughly eleven registered paths, so the long tail
evicts. Plan 02-08 tunes it against on-device memory; do not change it here.
Extend `keepAliveLifecycle.test.ts` with the four behaviors above. The eviction test
is the important one: navigate through `KEEP_ALIVE_MAX + 2` registered paths with
mount/unmount-counting stubs and assert the least recently used stub was unmounted.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveLifecycle.test.ts src/views/dashboard/__tests__/keepAliveTabs.test.ts && npm run test && npm run type-check && npm run build</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` imports `TAB_ORDER` from `useRouteTransitions` and builds `KEEP_ALIVE_PATHS` from it
- `shouldKeepAlive({ path: '/dashboard/apps' })` is true and `shouldKeepAlive({ path: '/dashboard/apps/bitcoin' })` is false — asserted in the test file
- The same false assertion holds for `/dashboard/marketplace/x`, `/dashboard/cloud/x`, `/dashboard/server/openwrt`, `/dashboard/web5/credentials` and `/dashboard/settings/update`
- The eviction test navigates through more than `KEEP_ALIVE_MAX` registered paths and asserts exactly `KEEP_ALIVE_MAX` instances remain resident
- `neode-ui/src/views/Apps.vue` arms its connection timer from `onActivated` and clears it from `onDeactivated`
- `neode-ui/src/views/Discover.vue` renders `RefreshIndicator` bound to the shared catalog resource's `loadState`
- `npm run test` exits 0, `npm run type-check` exits 0, `npm run build` exits 0
- The built bundle carries the widened set: `grep -rl "KEEP_ALIVE\|shouldKeepAlive" web/dist/neode-ui/assets | head -1` prints a file
- The SUMMARY lists every registered path and every excluded path with its measured reason
</acceptance_criteria>
<done>Every main tab that measurably remounts is registered, every one-shot and entry-scoped side effect is correctly placed, no secondary screen slipped into the instance cache, and eviction is proven by test.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Walk every main tab and confirm instant revisits with no off-screen drain</name>
<what-built>
Instance caching extended from the single tracer tab to every main tab that the
profiling pass showed remounting, with each tab's timers, websocket subscriptions and
window listeners moved onto the activate/deactivate lifecycle so an off-screen tab
costs nothing, and re-entering a live-data tab refreshes it immediately. The instance
cache is capped at 6 with least-recently-used eviction.
</what-built>
<how-to-verify>
1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview
pointed at archi-dev (password `password123`).
2. Visit every main tab once in order: Home, Apps, App store, Cloud, Mesh, Server,
Web5, Fleet, Chat, Settings. Let each finish loading.
3. Now switch between them at random. Expected on every revisit: content appears
immediately, scroll position and in-page state (search text, selected sub-tab,
expanded panels) are as you left them, and no intro animation replays.
4. Home specifically: note the wallet balance, leave Home for a minute, come back.
Expected: the previous figure is on screen instantly AND it updates within a second
or two as the immediate re-entry refresh lands — it must not sit frozen waiting for
the next 30s poll.
5. Chat specifically: open Chat, wait for the AIUI panel to load, switch away, switch
back. Expected: the panel is still loaded — it does not reload from scratch.
6. Apps specifically: with the backend running, open Apps, leave, and come back.
Expected: no spurious "Unable to connect to server" message appears.
7. Open a secondary screen from any tab (an app detail page, a cloud folder, the
OpenWrt page), navigate away and back. Expected: these still behave as before —
they are deliberately not instance-cached.
8. Cycle through all ten tabs twice, then return to the first one you visited.
Expected: it may show a brief load — it was evicted by the cap. This is correct.
9. Leave the browser sitting on one tab for a few minutes after having visited all of
them. The node should be idle; if the machine's fan spins up or the UI gets
sluggish, an off-screen timer is still running — report which tab you visited last.
</how-to-verify>
<resume-signal>Type "approved", or describe what you saw: which step, which tab, what happened instead.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| off-screen view instance → node resources | A deactivated but resident view can hold timers, sockets and heavy graphics contexts against a low-power fleet node |
| cached render → user's belief about liveness | A surviving instance shows figures that were true when the tab was last visible, not necessarily now |
| main-tab path prefix → secondary screen | A loose path match would sweep secondary screens into the instance cache |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-03 | Denial of Service | Resident view instances and their timers on low-power fleet hardware | medium | mitigate | `KEEP_ALIVE_MAX` of 6 with LRU eviction (D-03); Task 1 stops every interval, subscription and listener on `onDeactivated`; Task 3 step 9 and plan 02-08 check idle CPU and memory on archi-dev-box |
| T-02-13 | Spoofing | A stale wallet balance or peer-reachability figure rendered as if current | high | mitigate | Task 1 requires `onActivated` to fire an immediate loader call for every live-data surface, so a resumed tab revalidates on the frame it returns rather than waiting out a paused poll; the `RefreshIndicator` from 02-02 makes the in-flight refresh visible |
| T-02-14 | Information Disclosure | A secondary screen accidentally instance-cached by a widened path match | medium | mitigate | Task 2 keeps exact-path matching and asserts `shouldKeepAlive` is false for six representative secondary-screen paths whose prefixes match a registered tab |
| T-02-15 | Denial of Service | A double-armed timer or duplicate subscription after repeated activations | low | mitigate | Task 1 requires every start to be idempotent and asserts that two consecutive activations do not double-arm |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope; `onActivated` and `onDeactivated` are Vue core. A task that finds it needs a new dependency stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Symbols and paths created or changed by this plan — new API, not drift:
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts``KEEP_ALIVE_PATHS` widened from the tracer seed to the audited main-tab set, now derived from `TAB_ORDER`
- `neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts`
- `onActivated` / `onDeactivated` handlers added to `Home.vue`, `Chat.vue`, `Apps.vue`, and to `Web5.vue`, `Cloud.vue`, `Server.vue`, `Mesh.vue` where their greps turn up only-while-visible side effects
Created elsewhere in Phase 02: `shouldKeepAlive()`, `KEEP_ALIVE_MAX`,
`DashboardRouterView.vue`, `RefreshIndicator.vue`, `resources.clearAll()`,
`useCachedResource.test.ts`, `keepAliveTabs.test.ts`, `secondaryScreenCache.test.ts`,
`resourcesClear.test.ts`, `e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`,
cache keys `app-catalog`, `bitcoin.prune-status`, `app-details:<dataset>:<id>`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped; surfaced here for human review. Resolved in substance by this plan's `must_haves.truths`, with the off-screen-CPU truth carried as a `verification: backstop` marker because a unit test can prove a timer handle was cleared but not that the process draws no CPU — that half is checked on hardware in plan 02-08.
- **Open (RESEARCH pitfall 4 breadth):** RESEARCH.md names `Apps.vue`'s connection timer and `Server.vue`'s seven-call initializer as the known instances. This planner additionally found `Home.vue`'s two `setInterval` handles plus a `wsClient.subscribe`, and `Chat.vue`'s `window` message listener plus `ContextBroker`. `Cloud.vue`, `Web5.vue` and `Mesh.vue` were not exhaustively read — Task 1 greps each for `onMounted`, `onBeforeUnmount`, `onUnmounted`, `setInterval` and `subscribe` and handles whatever it finds. If a view turns out to hold a side effect none of those five tokens catch, record it in the SUMMARY rather than letting it pass.
- **Open (D-01 versus D-02 boundary):** D-01 says main tabs use both `<KeepAlive>` and `useCachedResource`; D-02 says already-fast views are left alone. This plan resolves the tension by measurement: a main tab is registered when `02-FINDINGS.md` records `Remounted: true` for it, and excluded when it is classified already fast with no remount cost. Every exclusion is recorded with its measured reason.
- **FA-D (RESEARCH assumption A2):** `KEEP_ALIVE_MAX` stays at 6 here and is tuned against real on-device memory in plan 02-08, not guessed at again in this plan.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0 and the widened registration appears in `web/dist/neode-ui/assets`
- The human-verify checkpoint is approved against archi-dev on the :8100 preview, including the Home wallet-freshness step and the eviction step
</verification>
<success_criteria>
- Every main tab that measurably remounted now renders instantly from a surviving instance, with scroll and in-page state intact
- No off-screen tab runs a timer, a subscription or a listener
- Re-entering a live-data tab revalidates immediately rather than waiting out its poll
- One-shot flags fire once; entry-scoped guards re-arm per entry
- The instance cache is capped and evicts, proven by test and observed on device
- No secondary screen was instance-cached, and every excluded main tab has a recorded measured reason
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-04-SUMMARY.md` when done. It MUST record:
the per-view table of every side effect and the bucket it was placed in; the final
`KEEP_ALIVE_PATHS` contents; every main-tab path excluded from registration with its
measured reason; and any side effect found that the five-token grep would have missed.
Plans 02-05, 02-06, 02-07 and 02-08 read all four from this file.
</output>

View File

@ -0,0 +1,303 @@
---
phase: 02-ui-performance
plan: 05
type: execute
wave: 4
depends_on: ["02-04"]
files_modified:
- neode-ui/src/views/Mesh.vue
- neode-ui/src/stores/mesh.ts
- neode-ui/src/stores/transport.ts
- neode-ui/src/views/__tests__/meshTabCache.test.ts
autonomous: true
requirements: [PERF-02]
must_haves:
truths:
- "Returning to the Mesh tab within the TTL issues no RPC for any of its six fetch groups"
- "Returning to the Mesh tab after the TTL keeps the peer graph and map on screen while exactly one background revalidation runs per stale group"
- "The six fetch groups still run concurrently on a cold load — the conversion does not serialize them"
- "Peer reachability and sync status shown on the Mesh tab are revalidated on re-entry, never left frozen at their last-visible values"
- "The D3 force simulation stops while the Mesh tab is off screen and resumes when it is re-entered"
- "The Leaflet map renders correctly after re-entry rather than showing an unsized or partially tiled canvas"
- "Repeatedly entering and leaving the Mesh tab creates one D3 simulation and one Leaflet map instance in total, not one per visit"
- statement: "Cycling the Mesh tab twenty times leaves heap usage flat rather than growing monotonically"
verification: backstop
prohibitions:
- "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation"
- "MUST NOT persist wallet balances, transaction history, credentials, DIDs, seed or identity material, or peer identity payloads to sessionStorage"
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
artifacts:
- path: "neode-ui/src/stores/mesh.ts"
provides: "Mesh refresh path backed by a cached resource so every consumer shares one entry"
- path: "neode-ui/src/stores/transport.ts"
provides: "Transport status backed by a cached resource"
- path: "neode-ui/src/views/__tests__/meshTabCache.test.ts"
provides: "Call-count assertions per fetch group plus simulation and map lifecycle assertions"
key_links:
- from: "neode-ui/src/views/Mesh.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "each of the six fetch groups becomes a keyed cached resource"
pattern: "useCachedResource"
- from: "neode-ui/src/views/Mesh.vue"
to: "vue onActivated / onDeactivated"
via: "the D3 simulation is stopped on deactivate and the Leaflet map is re-sized on activate"
pattern: "onDeactivated"
---
<objective>
Cache the Mesh tab's six uncached fetch groups and make its D3 force graph and Leaflet map
correct and bounded now that the tab's component instance survives tab switches.
Purpose: PERF-02. Mesh is the heaviest main tab in the app — 2,651 lines, a live D3 force
simulation and a Leaflet map — and RESEARCH.md's code-level scan found its `onMounted`
already correctly parallel (`await Promise.all([...])` across six groups) but nothing
cached, so all six re-run on every tab entry. It is also the tab D-03 singles out for
bounded memory. It is planned separately from the other tabs purely on context cost: its
size exceeds what a shared task can hold.
Output: six cached fetch groups with per-dataset TTLs, and a graph and map that survive
deactivation without leaking, freezing, or mis-rendering.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/phases/02-ui-performance/02-02-SUMMARY.md
@.planning/phases/02-ui-performance/02-04-SUMMARY.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Cache the six Mesh fetch groups without serializing them</name>
<files>neode-ui/src/views/Mesh.vue, neode-ui/src/stores/mesh.ts, neode-ui/src/stores/transport.ts, neode-ui/src/views/__tests__/meshTabCache.test.ts</files>
<read_first>
- `neode-ui/src/views/Mesh.vue` — 2,651 lines; do NOT read it whole. Grep for `onMounted`, `onActivated`, `refreshAll`, `fetchStatus`, `refreshFederationNodes`, `refreshSelfOnion`, `refreshSelfDid`, `refreshContacts` and `useCachedResource`, then read only those regions. The `onMounted` body is `await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])`.
- `neode-ui/src/stores/mesh.ts` — read `refreshAll()` and whatever it fans out to; decide whether the cache belongs behind the store action (shared by every consumer) or in the view.
- `neode-ui/src/stores/transport.ts` — read `fetchStatus()` for the same decision.
- `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for resource definition and keep-last-value error handling.
- `neode-ui/src/composables/useCachedResource.ts` — the options contract and returned surface, including the `onActivated` revalidation added by plan 02-02.
- `neode-ui/src/api/rpc-client.ts` — the `dedup: true` option to pass on every fetcher.
- `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the side-effect bucket table for `Mesh.vue`, so this task does not re-litigate placements already decided.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — Mesh's measured revisit RPC count and primary cause.
</read_first>
<behavior>
- Mounting the Mesh tab, deactivating, and reactivating inside the TTL issues zero new RPCs across all six groups
- Reactivating after the TTL issues exactly one revalidation per stale group, with the previous graph and peer list still rendered
- A cold load still issues all six groups concurrently — the recorded start times overlap rather than forming a chain
- A rejected refresh in one group leaves the other five unaffected and leaves that group's last known data rendered
- Peer reachability data has a shorter TTL than near-static identity data, so a resumed tab does not show a long-stale reachability state
</behavior>
<action>
Convert each of the six fetch groups to a `useCachedResource` entry, following the
`Cloud.vue` pattern. Place each cache at the level that serves the most consumers:
`mesh.refreshAll()` and `transport.fetchStatus()` are store actions with other
callers, so cache behind the store action; the four view-local refreshers
(`refreshFederationNodes`, `refreshSelfOnion`, `refreshSelfDid`, `refreshContacts`)
cache in the view. Record the placement decision per group in the SUMMARY.
Set `ttlMs` explicitly per group rather than taking the default, using the D-06
discretion. Peer and transport state move fast and warrant a short TTL of around
10000 ms; federation node lists sit at the 30000 ms default; this node's own onion
address and DID are effectively static and warrant a long TTL of 300000 ms or more.
Choose a value per group and give the reason in the SUMMARY.
Set `persist` explicitly per group. This node's own DID and onion address, and any
peer identity payload (peer DIDs, pubkeys, onion addresses, contact records), are
memory-only: declare `persist: false`. Non-identity aggregate counts and transport
status may persist. This is the sessionStorage privacy prohibition in this plan's
`must_haves`, and it is not negotiable against a shorter first paint.
Pass `dedup: true` on every underlying `rpcClient.call`.
Keep the fan-out concurrent, per D-13: waterfalls are fixed client-side by
parallelizing plus rpc-client dedup. The `onMounted` `Promise.all` must stay a single
awaited group; converting each call into a separately-awaited cached refresh would
turn an already-parallel load into the exact waterfall this phase exists to remove. If
a group's refresh must be kicked explicitly, use `immediate: false` on the resource and
call `refresh()` inside the same `Promise.allSettled` array, as `Cloud.vue` does with
`peersResource`. Prefer `allSettled` over `all` so one failing group does not suppress
the other five. D-13 reserves a new aggregate endpoint for a screen needing three or
more genuinely dependent calls; Mesh's six groups are independent, so none is
warranted here. Should one become necessary, D-12 bounds it to an additive new handler
with no refactor of existing handlers and nothing touching the orchestrator — stop for
a checkpoint before any `core/` change, since no backend work is in this plan's scope.
Wire `RefreshIndicator` (from plan 02-02) into the Mesh header, driven by whether any
of the six groups is in `refreshing` — the subtle in-header signal D-05 specifies, not
a stale-age badge. Peer reachability is a liveness-critical figure:
when the tab is re-entered and the reachability group is stale, the indicator must be
visible while it revalidates, so a resumed tab never presents a frozen reachability
state as current.
Error handling follows D-07: keep the last known value, set the view's existing error
ref for a banner, raise no toast.
Create `neode-ui/src/views/__tests__/meshTabCache.test.ts` covering the five behaviors
above. Mock the store actions and the RPC client with `vi.fn()` fetchers, mount inside
a `<KeepAlive>`, and assert call counts per group across a deactivate/reactivate
cycle with fake timers. For the concurrency assertion, record invocation timestamps
and assert the six starts overlap rather than forming a chain.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/meshTabCache.test.ts && npm run test && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/Mesh.vue` imports `useCachedResource`, and `neode-ui/src/stores/mesh.ts` and `neode-ui/src/stores/transport.ts` each route their refresh through a cached entry
- `npm run test -- src/views/__tests__/meshTabCache.test.ts` exits 0 with all five behaviors covered
- The reactivate-inside-TTL test asserts zero additional fetcher calls across all six groups
- The concurrency test asserts the six cold-load fetchers overlap in time
- Every group carrying this node's DID or onion address, or any peer identity payload, is declared `persist: false`
- Every fetcher passes `dedup: true`
- `neode-ui/src/views/Mesh.vue` renders `RefreshIndicator`
- `npm run test` exits 0 and `npm run type-check` exits 0
- The SUMMARY records, per group: cache placement, TTL, persist choice, and reason
</acceptance_criteria>
<done>All six Mesh fetch groups are cached with deliberate TTLs and persist choices, a revisit inside the TTL issues no RPC, the cold-load fan-out is still concurrent, and no identity payload reaches sessionStorage.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Bound the D3 simulation and Leaflet map across deactivation</name>
<files>neode-ui/src/views/Mesh.vue, neode-ui/src/views/__tests__/meshTabCache.test.ts</files>
<read_first>
- `neode-ui/src/views/Mesh.vue` — grep for `d3`, `forceSimulation`, `simulation`, `requestAnimationFrame`, `LMap`, `leaflet`, `invalidateSize`, `ResizeObserver` and `addEventListener`, then read only those regions.
- `neode-ui/package.json` — confirms `d3` and the Leaflet bindings are direct dependencies; no new package is needed here.
- `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the side-effect bucket table for `Mesh.vue` from the lifecycle audit; this task extends it to the graphics contexts specifically.
- `.planning/phases/02-ui-performance/02-RESEARCH.md` pitfall 6 — the memory-growth failure mode on low-power fleet nodes that this task prevents.
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-03 keeps Mesh alive but requires bounded memory.
</read_first>
<behavior>
- Deactivating the Mesh tab stops the D3 force simulation, so its tick callback is not invoked while the tab is off screen
- Reactivating restarts the simulation only if the graph data changed while away; otherwise the graph is left at its settled layout rather than re-heating and visibly re-animating
- Deactivating cancels any pending animation-frame callback the view owns
- Reactivating calls the Leaflet map's size-invalidation so the map paints correctly after being laid out while hidden
- Entering and leaving the tab three times constructs exactly one simulation and one map instance
- Any window or resize listener the view registers is removed on deactivate and re-added exactly once on activate
</behavior>
<action>
Extend `Mesh.vue`'s activate/deactivate handling (established in plan 02-04) to cover
its two graphics contexts. Under an instance cache these are constructed once and then
live for the session, which is exactly what D-03 wants — but only if they are quiesced
while off screen and repaired on return.
On deactivate: stop the D3 force simulation rather than destroying it, cancel any
pending animation-frame handle the view owns, and remove any window or resize listener
the view registered. On activate: re-add the listener exactly once (clearing any prior
handle first, since `onActivated` also fires on first mount), call the Leaflet map's
size-invalidation on `nextTick` so a map laid out while hidden re-tiles at its real
size, and restart the simulation only when the underlying graph data changed while
away. Restarting unconditionally would replay the layout animation on every tab entry,
which reads as the sluggishness this phase is removing.
Do not destroy and rebuild either context on deactivate. Rebuilding is what today's
remount already does and is the cost being eliminated; keeping one instance for the
session is the point.
Add the six behaviors above to `meshTabCache.test.ts`. Stub `d3` and the Leaflet
binding at the module boundary with `vi.mock` so the assertions are on the calls made
(simulation stop and restart, size-invalidation, listener add and remove, constructor
invocation counts) rather than on real rendering, which jsdom cannot do.
Instance-count growth across many tab cycles is a heap property that a unit test
cannot settle. Record the design in the SUMMARY so plan 02-08 can check it on
archi-dev-box with the browser's memory tooling.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/meshTabCache.test.ts && npm run test && npm run type-check && npm run build</automated>
</verify>
<acceptance_criteria>
- `npm run test -- src/views/__tests__/meshTabCache.test.ts` exits 0 with all six behaviors covered
- A test asserts the simulation's stop call happens on deactivate and its tick callback is not invoked while deactivated
- A test asserts three enter/leave cycles construct exactly one simulation and one map
- A test asserts the Leaflet size-invalidation is called on activate
- A test asserts a window or resize listener is added exactly once across two consecutive activations
- `neode-ui/src/views/Mesh.vue` references `onDeactivated`
- `npm run test` exits 0, `npm run type-check` exits 0, `npm run build` exits 0
- The SUMMARY records what runs on deactivate, what runs on activate, and the condition under which the simulation restarts
</acceptance_criteria>
<done>The Mesh graph and map are constructed once per session, quiesced while off screen, repaired on return without replaying their entry animation, and the heap check is handed off to the on-device plan with a documented design.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| peer node identity data → local browser storage | Peer DIDs, pubkeys, onion addresses and contact records cross into this node's browser cache |
| resident graphics context → node resources | A live D3 simulation and Leaflet map held for the session against low-power fleet hardware |
| cached peer state → operator's belief about reachability | A settled graph shows the mesh as it was when the tab was last visible |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-01 | Information Disclosure | Peer identity payloads and this node's own DID/onion written to sessionStorage by the default `persist: true` | high | mitigate | Task 1 requires `persist: false` on every group carrying a DID, onion address, pubkey or contact record; only non-identity aggregates may persist |
| T-02-03 | Denial of Service | Resident D3 simulation and Leaflet map on low-power fleet hardware | medium | mitigate | Task 2 stops the simulation and cancels animation frames on deactivate, constructs exactly one of each per session, and `KEEP_ALIVE_MAX` evicts Mesh under pressure; heap growth is checked on device in plan 02-08 |
| T-02-13 | Spoofing | A frozen peer-reachability state rendered as current after a resumed tab | high | mitigate | Task 1 gives reachability the shortest TTL of the six groups and requires the `RefreshIndicator` to be visible while it revalidates on re-entry |
| T-02-16 | Denial of Service | Converting the parallel six-group fan-out into a serial chain of awaited refreshes | medium | mitigate | Task 1 forbids per-group awaiting, requires `immediate: false` plus a single `Promise.allSettled`, and asserts overlapping start times in test |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope; `d3` and the Leaflet bindings are already direct dependencies of `neode-ui`. A task that finds it needs a new dependency stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Created or changed by this plan — new API, not drift:
- `neode-ui/src/views/__tests__/meshTabCache.test.ts`
- Cached-resource entries behind `stores/mesh.ts` `refreshAll()` and `stores/transport.ts` `fetchStatus()`
- Mesh cache keys for federation nodes, self onion, self DID and contacts
- `onDeactivated` / extended `onActivated` handling in `Mesh.vue` for the D3 simulation and Leaflet map
Created elsewhere in Phase 02: `shouldKeepAlive()`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`,
`DashboardRouterView.vue`, `RefreshIndicator.vue`, `resources.clearAll()`,
`useCachedResource.test.ts`, `keepAliveTabs.test.ts`, `keepAliveLifecycle.test.ts`,
`secondaryScreenCache.test.ts`, `resourcesClear.test.ts`,
`e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped; surfaced here for human review. Resolved in substance by this plan's `must_haves.truths`, with the heap-growth truth carried as a `verification: backstop` marker — a jsdom test can prove one constructor call but not flat heap usage across twenty cycles, which is checked on hardware in plan 02-08.
- **Open:** whether `mesh.refreshAll()` and `transport.fetchStatus()` have consumers outside `Mesh.vue` is not settled from the route table alone; Task 1 reads both stores and records the cache-placement decision per group in the SUMMARY.
- **Open:** whether restarting the D3 simulation on re-entry is even desirable depends on whether the graph data changed while away. Task 2 makes the restart conditional and records the condition; if the settled-layout behavior reads wrong on device, plan 02-08's walkthrough is where that surfaces.
- **Note:** RESEARCH.md is explicit that `Mesh.vue`'s `onMounted` is already correctly parallel and must not be "fixed". This plan converts what those calls read from, not the order they run in.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0
- Mesh revisit RPC count is zero inside the TTL, asserted in `meshTabCache.test.ts`
</verification>
<success_criteria>
- A revisit to Mesh inside the TTL issues no RPC and paints the previous graph and peer list immediately
- A stale revisit revalidates each stale group exactly once, visibly, without clearing the screen
- The cold-load fan-out is still concurrent
- No peer or self identity payload is written to sessionStorage
- One D3 simulation and one Leaflet map exist per session, quiesced off screen and repaired on return
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-05-SUMMARY.md` when done. It MUST record:
per fetch group, its cache placement, TTL, persist choice and reason; what runs on
deactivate and on activate for the graph and map; and the simulation-restart condition.
Plan 02-08 reads the persist table and the graphics design when checking memory on device.
</output>

View File

@ -0,0 +1,312 @@
---
phase: 02-ui-performance
plan: 06
type: execute
wave: 4
depends_on: ["02-04"]
files_modified:
- neode-ui/src/views/Server.vue
- neode-ui/src/views/Home.vue
- neode-ui/src/views/__tests__/serverTabCache.test.ts
- neode-ui/src/views/__tests__/homeTabCache.test.ts
autonomous: true
requirements: [PERF-02]
must_haves:
truths:
- "Returning to the Server tab within the TTL issues no RPC for any of its seven load groups"
- "Returning to the Home tab within the TTL issues no RPC for its system, update, wallet or storage-usage groups"
- "A stale return to either tab keeps the previous content on screen while exactly one background revalidation runs per stale group, with a visible refresh indicator"
- "The Server tab's seven loads still run concurrently — the conversion does not turn a parallel fan-out into a chain"
- "Any Server load that genuinely consumes another load's result remains ordered, and the dependency is recorded rather than assumed away"
- "The wallet figures on Home are revalidated on tab re-entry, so a resumed tab never presents a paused-poll balance as current"
- "Wallet balances, transaction history and identity material from either tab are held in memory only and never written to sessionStorage (D-08)"
- statement: "First entry to either tab in a fresh session may still show a loading state; only revisits are required to be instant"
verification: backstop
prohibitions:
- "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation"
- "MUST NOT persist wallet balances, transaction history, credentials, DIDs, seed or identity material, or peer identity payloads to sessionStorage"
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
artifacts:
- path: "neode-ui/src/views/__tests__/serverTabCache.test.ts"
provides: "Per-group call-count assertions and a concurrency assertion for the seven Server loads"
- path: "neode-ui/src/views/__tests__/homeTabCache.test.ts"
provides: "Call-count assertions plus the wallet-freshness-on-re-entry assertion"
key_links:
- from: "neode-ui/src/views/Server.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "each of the seven load groups becomes a keyed cached resource"
pattern: "useCachedResource"
- from: "neode-ui/src/views/Home.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "system stats, update status, wallet status and storage usage become keyed cached resources"
pattern: "useCachedResource"
- from: "neode-ui/src/views/Home.vue"
to: "neode-ui/src/components/RefreshIndicator.vue"
via: "the wallet card's refresh state drives the indicator so a resumed balance is never shown as settled"
pattern: "RefreshIndicator"
---
<objective>
Cache the two remaining uncached-fetch main tabs: Server, whose seven independent loads
re-run in full on every tab entry, and Home, whose system, update, wallet and storage
figures do the same on top of two polling intervals.
Purpose: PERF-02. RESEARCH.md classifies both as uncached fetch rather than as waterfalls
— their calls are already concurrent — so the work here is caching, not reordering. Home
carries the phase's sharpest liveness constraint: a wallet balance is the one figure where
"instant from cache" must never mean "quietly out of date".
Output: both tabs' fetches on keyed cached resources with deliberate TTLs and persist
choices, still concurrent on cold load, with wallet freshness guaranteed on re-entry.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/phases/02-ui-performance/02-02-SUMMARY.md
@.planning/phases/02-ui-performance/02-04-SUMMARY.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Cache the Server tab's seven load groups</name>
<files>neode-ui/src/views/Server.vue, neode-ui/src/views/__tests__/serverTabCache.test.ts</files>
<read_first>
- `neode-ui/src/views/Server.vue` — 889 lines; grep for `onMounted`, `onActivated`, `useCachedResource`, `checkTorStatus`, `loadNetworkData`, `loadInterfaces`, `loadDiskStatus`, `loadTorServices`, `loadVpnPeers` and `loadFipsSummary` first, then read the `onMounted` block at line ~831 and each loader body. Do not read the file whole.
- `neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts` — an existing test for this view; follow its mocking setup and extend rather than duplicate its conventions.
- `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the resource-definition and keep-last-value reference.
- `neode-ui/src/composables/useCachedResource.ts` — the options contract, including the `onActivated` revalidation added by plan 02-02.
- `neode-ui/src/api/rpc-client.ts` — the `dedup: true` option.
- `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the side-effect bucket table for `Server.vue`.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — Server's measured revisit RPC count and primary cause.
- `.planning/phases/02-ui-performance/02-RESEARCH.md` assumption A3 — flags that the seven loads are *assumed* independent and that the assumption is unverified.
</read_first>
<behavior>
- Mounting Server, deactivating, and reactivating inside the TTL issues zero new RPCs across all seven groups
- Reactivating after the TTL issues exactly one revalidation per stale group with previous content still rendered
- A cold load issues the independent groups concurrently — their recorded start times overlap
- A rejected refresh in one group leaves the other six unaffected and keeps that group's last known data rendered
- Disk and network state carry a shorter TTL than FIPS summary state, so fast-moving figures do not sit stale
</behavior>
<action>
Convert each of the seven loads to a keyed `useCachedResource` entry following the
`Cloud.vue` pattern, with `computed` views over `entry.data` and `entry.loadState`
and keep-last-value error handling that sets the view's existing error ref rather
than raising a toast (D-07). Some sub-cards in this view already use the hook —
reuse their keys rather than introducing a second entry for the same dataset, and
say in the SUMMARY which ones you found.
Before parallelizing or caching anything, settle RESEARCH.md assumption A3: the seven
loads are *assumed* independent, and that assumption is explicitly flagged as
unverified. Read each loader body and confirm none of the seven consumes another's
result or side effect. Record the verdict per loader in the SUMMARY. If any pair does
have a real dependency, keep that pair ordered and cache them individually — do not
flatten a genuine dependency into a concurrent group to make a number look better.
Set `ttlMs` explicitly per group using the D-06 discretion: disk status, network data
and interface state move fast enough for a short TTL of around 10000 ms; Tor status,
Tor services and VPN peers sit near the 30000 ms default; the FIPS summary is
near-static and warrants a longer value. Give the reason per group in the SUMMARY.
Set `persist` explicitly per group. Anything carrying VPN peer identity, Tor onion
addresses or key material is memory-only (`persist: false`). Non-identity system
figures may persist.
Pass `dedup: true` on every underlying `rpcClient.call`.
Keep the fan-out concurrent, per D-13. `Server.vue`'s `onMounted` today issues all
seven without awaiting them in sequence, which is already correct; the conversion must
preserve that shape. Where a resource needs an explicit kick, use `immediate: false`
and call `refresh()` inside a single `Promise.allSettled` array. D-13 reserves an
aggregate endpoint for a screen needing three or more genuinely dependent calls — if
the A3 verdict turns up such a chain here, D-12 bounds the response to an additive new
handler with no refactor of existing handlers and nothing touching the orchestrator,
and the task stops for a checkpoint before any `core/` change since no backend work is
otherwise in this plan's scope.
Wire `RefreshIndicator` into the Server header, driven by whether any group is
`refreshing` — the subtle in-header signal D-05 specifies, not a stale-age badge.
Create `neode-ui/src/views/__tests__/serverTabCache.test.ts` covering the five
behaviors above with `vi.fn()` fetchers, `<KeepAlive>` mounting, fake timers and
recorded invocation timestamps for the concurrency assertion.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/serverTabCache.test.ts src/views/__tests__/ServerNetworkRefresh.test.ts && npm run test && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/Server.vue` imports `useCachedResource` and every one of the seven loads resolves through a cached entry
- `npm run test -- src/views/__tests__/serverTabCache.test.ts` exits 0 with all five behaviors covered
- The reactivate-inside-TTL test asserts zero additional fetcher calls across all seven groups
- The concurrency test asserts the independent groups' cold-load starts overlap
- The pre-existing `ServerNetworkRefresh.test.ts` still passes unmodified in intent
- Groups carrying VPN peer identity, onion addresses or key material are declared `persist: false`
- Every fetcher passes `dedup: true`
- `neode-ui/src/views/Server.vue` renders `RefreshIndicator`
- `npm run test` exits 0 and `npm run type-check` exits 0
- The SUMMARY records the A3 independence verdict per loader, plus each group's TTL, persist choice and reason
</acceptance_criteria>
<done>All seven Server loads are cached with verified independence, deliberate TTLs and persist choices; a revisit inside the TTL issues no RPC; the cold-load fan-out is still concurrent.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Cache the Home tab and guarantee wallet freshness on re-entry</name>
<files>neode-ui/src/views/Home.vue, neode-ui/src/views/__tests__/homeTabCache.test.ts</files>
<read_first>
- `neode-ui/src/views/Home.vue` lines 293 and 524-560 — the `onMounted` block calls `hydrateWalletSnapshot()`, `loadSystemStats()`, `checkUpdateStatus()`, `loadWeb5Status()` and `await fileBrowserClient.getUsage()`, and arms `systemStatsInterval` (10s), `walletRefreshInterval` (30s), a `wsClient.subscribe` and a `wsWalletDebounce`. Read each loader body too.
- `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the bucket table for `Home.vue`; plan 02-04 already moved the intervals and the websocket subscription onto activate/deactivate and required an immediate loader call on re-entry. This task must build on that placement, not undo it.
- `neode-ui/src/views/web5/Web5.vue` lines 140 and 293 — two existing `useCachedResource` definitions in this codebase covering wallet-adjacent data; check whether Home can share a key with them rather than creating a parallel entry for the same dataset.
- `neode-ui/src/components/RefreshIndicator.vue` — as created by plan 02-02, and its `state` prop typing.
- `neode-ui/src/composables/useCachedResource.ts` and `neode-ui/src/stores/resources.ts` — the `persist` option and the sessionStorage snapshot path it controls.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — Home's measured revisit RPC count and primary cause.
</read_first>
<behavior>
- Mounting Home, deactivating, and reactivating inside the TTL issues zero new RPCs for the system, update and storage-usage groups
- Reactivating always triggers a wallet revalidation regardless of TTL, and the refresh indicator is visible while it is in flight
- The wallet figure previously on screen stays rendered throughout that revalidation — the card never blanks or falls back to a skeleton
- A rejected wallet refresh leaves the last known figure rendered and raises no toast
- No wallet balance, transaction record or identity value is written to sessionStorage by any Home resource
- The existing wallet snapshot hydration still paints last-known figures before any network round-trip
</behavior>
<action>
Convert Home's fetches to keyed cached resources: system stats, update status, wallet
or Web5 status, and cloud storage usage. Where `Web5.vue` already defines a resource
for the same dataset, share its key rather than creating a second entry for the same
data.
Set `ttlMs` explicitly per group: system stats are fast-moving and should carry a
short TTL matching the existing 10s poll cadence; update status is near-static and
warrants a long value; storage usage sits near the default.
Wallet is the exception this task exists for, and it does not get a normal TTL-gated
treatment. A balance is a money figure: showing yesterday's number with no visible
signal that it is being re-checked is the failure this plan's first prohibition
forbids. So on tab re-entry the wallet resource revalidates unconditionally rather
than only when its TTL has lapsed, the previously known figure stays rendered
throughout, and `RefreshIndicator` is bound to that resource's `loadState` so the
re-check is visible. Plan 02-04 already placed an immediate loader call in
`onActivated`; wire the cached resource so that call is what revalidates it, rather
than adding a second independent call path.
Declare `persist: false` for the wallet or Web5 status resource and for anything else
carrying balances, transaction history, DIDs or identity material. The existing
`hydrateWalletSnapshot()` mechanism stays exactly as it is — it is the view's own
deliberate last-known-figures path and is not being replaced by the resource cache.
Storage usage and system stats are non-sensitive and may persist.
Pass `dedup: true` on every underlying call. Keep the existing concurrency: today's
`onMounted` fires the loaders without awaiting them in sequence except for the
`fileBrowserClient.getUsage()` await; move that into the same `Promise.allSettled`
group rather than leaving it as a trailing await.
Leave the websocket-driven wallet refresh from plan 02-04 in place. It is what makes a
zero-confirmation incoming transaction appear in seconds, and removing or debouncing
it harder to reduce request counts would be exactly the metric-gaming this plan's
third prohibition forbids.
Create `neode-ui/src/views/__tests__/homeTabCache.test.ts` covering the six behaviors
above. The sessionStorage assertion should seed the store, mount, deactivate and
reactivate, then assert no `resource:` key exists for the wallet entry.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/homeTabCache.test.ts && npm run test && npm run type-check && npm run build</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/views/Home.vue` imports `useCachedResource` and renders `RefreshIndicator` bound to the wallet resource's `loadState`
- `npm run test -- src/views/__tests__/homeTabCache.test.ts` exits 0 with all six behaviors covered
- A test asserts reactivation triggers a wallet revalidation even when the TTL has not lapsed
- A test asserts the previously rendered wallet figure is still in the DOM during that revalidation
- A test asserts no sessionStorage key exists for the wallet resource after a mount and reactivation cycle
- The wallet or Web5 status resource is declared `persist: false`
- `hydrateWalletSnapshot` is still called from `onMounted` and still paints before any network call
- The `wsClient.subscribe` wallet-push path from plan 02-04 is still present and still triggers a wallet refresh
- `npm run test` exits 0, `npm run type-check` exits 0, `npm run build` exits 0
- The SUMMARY records each Home key with its TTL, persist choice and reason, and states which keys are shared with `Web5.vue`
</acceptance_criteria>
<done>Home's system, update and storage figures come from cache on revisit, the wallet always re-checks visibly on re-entry without blanking or persisting, and the real-time wallet push path is intact.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| node RPC responses → browser cache | System, network, Tor, VPN and wallet payloads now live longer in memory and possibly in sessionStorage |
| authenticated session → sessionStorage | Anything persisted is readable by any script on the origin and survives reload |
| cached wallet figure → user's financial decision | A balance shown from cache can drive a send decision |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-01 | Information Disclosure | Wallet balances, transaction history, VPN peer identity and Tor onion addresses written to sessionStorage by the default `persist: true` | high | mitigate | Both tasks require explicit per-resource `persist` decisions; every identity-bearing or financial group is `persist: false`, asserted by a test in Task 2 |
| T-02-13 | Spoofing | A cached wallet balance rendered as current after a paused poll | high | mitigate | Task 2 revalidates the wallet unconditionally on re-entry rather than on TTL lapse, keeps the prior figure rendered, and binds `RefreshIndicator` to the in-flight state so the re-check is visible |
| T-02-17 | Tampering | Flattening a genuine load-order dependency among the seven Server loads to reduce measured latency | medium | mitigate | Task 1 requires RESEARCH assumption A3 to be settled by reading each loader body, with the verdict recorded per loader and any real dependency kept ordered |
| T-02-16 | Denial of Service | Converting an already-concurrent fan-out into a serial chain of awaited refreshes | medium | mitigate | Both tasks forbid per-group awaiting, require `immediate: false` plus a single `Promise.allSettled`, and assert overlapping start times |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope. A task that finds it needs a new dependency stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Created or changed by this plan — new API, not drift:
- `neode-ui/src/views/__tests__/serverTabCache.test.ts`
- `neode-ui/src/views/__tests__/homeTabCache.test.ts`
- Server cache keys for Tor status, network data, interfaces, disk status, Tor services, VPN peers and the FIPS summary
- Home cache keys for system stats, update status, wallet/Web5 status and cloud storage usage (some shared with `Web5.vue`)
Created elsewhere in Phase 02: `shouldKeepAlive()`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`,
`DashboardRouterView.vue`, `RefreshIndicator.vue`, `resources.clearAll()`,
`useCachedResource.test.ts`, `keepAliveTabs.test.ts`, `keepAliveLifecycle.test.ts`,
`meshTabCache.test.ts`, `secondaryScreenCache.test.ts`, `resourcesClear.test.ts`,
`e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped; surfaced here for human review. Resolved in substance by this plan's `must_haves.truths`, with the first-entry allowance carried as a `verification: backstop` marker because CONTEXT.md D-11 states it as an allowance rather than as an assertable check.
- **RESEARCH assumption A3 (carried, unresolved at plan time):** the seven `Server.vue` loads are assumed independent with no ordering dependency. This planner did not verify it either. Task 1 makes settling it a precondition of the conversion and requires a per-loader verdict in the SUMMARY — the assumption is not permitted to pass through silently.
- **Open:** whether Home can share wallet-adjacent cache keys with `Web5.vue`'s two existing resources is decided during Task 2 by reading both, and recorded in the SUMMARY. Two entries for one dataset would double the request count this plan is reducing.
- **Note:** the unconditional wallet revalidation on re-entry is a deliberate departure from the TTL-gated default. It costs one request per tab entry and buys the guarantee that a money figure is never presented as settled when it is merely cached.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0
- Server and Home revisit RPC counts are zero inside the TTL for their TTL-gated groups, asserted in their test files
</verification>
<success_criteria>
- Revisits to Server and Home paint from cache with no RPC for TTL-gated groups
- The wallet always re-checks visibly on re-entry while keeping its previous figure on screen
- No financial or identity payload from either tab is written to sessionStorage
- Both cold-load fan-outs remain concurrent, and any genuine ordering dependency among the Server loads is preserved and documented
- The real-time wallet push path is unchanged
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-06-SUMMARY.md` when done. It MUST record:
the RESEARCH A3 independence verdict per Server loader; every cache key introduced with
its TTL, persist choice and reason; which Home keys are shared with `Web5.vue`; and
confirmation that the websocket wallet-push path is intact.
</output>

View File

@ -0,0 +1,325 @@
---
phase: 02-ui-performance
plan: 07
type: execute
wave: 4
depends_on: ["02-04"]
files_modified:
- .planning/phases/02-ui-performance/02-AIUI-D14.md
- neode-ui/src/views/Chat.vue
- neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts
autonomous: false
requirements: [PERF-02]
user_setup:
- service: aiui
why: "D-14's two UX defaults are implemented in AIUI, whose source is a sibling repository not present in this checkout. The neode-ui side can only pass the flags; something must read them."
dashboard_config:
- task: "Confirm where the AIUI source checkout lives and make it reachable from the machine executing this phase (project notes record the ThinkPad at .116 as the primary build server; neode-ui's dev:mock script and scripts/setup-aiui-server.sh both expect it at ../../AIUI relative to neode-ui)"
location: "Developer's own machine / build server"
must_haves:
truths:
- "Switching away from the Chat tab and back leaves the AIUI panel loaded — the iframe is not re-created and does not reload"
- "The iframe src is stable for the lifetime of the Chat view instance: no runtime-varying value (viewport width, connection state, timestamp) is part of the URL"
- "The AIUI chat opens in its expanded state rather than requiring the user to expand it (D-14a)"
- "On a mobile viewport, AIUI opens on its chat view rather than on its context view (D-14b)"
- "The mechanism carrying both defaults across the neode-ui to AIUI boundary is recorded in writing, including which side implements which half"
- "The existing postMessage origin validation still rejects messages from any origin other than the AIUI URL's own"
- "The connected state established by AIUI's ready message is not reset when the Chat tab is deactivated, since that message is not re-sent on re-entry"
- statement: "Neither D-14 default regresses AIUI's desktop layout or its non-embedded standalone mode"
verification: backstop
prohibitions:
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
- "MUST NOT widen what the embedded AIUI iframe is granted or trusted as a side effect of passing presentation flags — this phase changes how AIUI opens, never what it may reach"
artifacts:
- path: ".planning/phases/02-ui-performance/02-AIUI-D14.md"
provides: "The recorded AIUI source location, its embed-parameter contract, and which side implements each D-14 default"
- path: "neode-ui/src/views/Chat.vue"
provides: "Stable embed URL carrying the D-14 presentation flags"
- path: "neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts"
provides: "Assertions that the embed URL is stable and carries both flags, and that origin validation is unchanged"
key_links:
- from: "neode-ui/src/views/Chat.vue"
to: "the AIUI application"
via: "embed flags appended to the iframe URL query string built by the aiuiUrl computed"
pattern: "aiuiUrl"
- from: "neode-ui/src/views/Chat.vue"
to: "vue onActivated / onDeactivated"
via: "the message listener and ContextBroker follow activation, established in plan 02-04"
pattern: "onDeactivated"
---
<objective>
Finish the Chat tab: make the embedded AIUI panel survive tab switches without reloading,
and land the two AIUI UX defaults CONTEXT.md folded into this phase (D-14).
Purpose: PERF-02 and D-14. Chat's per-switch cost is unlike every other tab's — it is not
an RPC fan-out, it is a full re-creation of an embedded application. Plan 02-04 made the
Chat view instance survive; this plan makes sure nothing in the URL construction quietly
undoes that. Adding a query parameter that varies at runtime would change the iframe's
`src`, force a reload on every re-render, and hand back the entire win — which is exactly
the risk D-14's two flags introduce, since one of them is about mobile.
D-14 is a locked decision and is delivered here in full. It has one genuine external
dependency: AIUI's source is a sibling repository that RESEARCH.md verified is not present
in this checkout, so the receiving half of the contract cannot be read from `archy` alone.
Task 1 resolves that before any code is written, and Task 2 will not start until it has.
Output: a recorded neode-ui-to-AIUI embed contract, a stable embed URL carrying both
defaults, and a Chat tab that keeps its loaded panel across tab switches.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-04-SUMMARY.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Locate AIUI and record the embed contract</name>
<files>.planning/phases/02-ui-performance/02-AIUI-D14.md</files>
<read_first>
- `neode-ui/src/views/Chat.vue` lines 61-125 — the `aiuiUrl` computed at line ~74 builds the query string today (`embedded=true`, `hideClose=true`, and in demo mode `mockArchy=1&seed=1`), and `onAiuiMessage` at line ~92 validates the message origin against that URL before accepting a `ready` message.
- `neode-ui/package.json` — the `dev:mock` script expects AIUI at `../../AIUI` relative to `neode-ui` and degrades to a placeholder when it is absent.
- `neode-ui/scripts/setup-aiui-server.sh` — the other place the sibling-repo path is encoded.
- `apps/aiui/manifest.yml` — describes a prebuilt container image (`localhost/archipelago-aiui:latest`) with no source in this checkout.
- `neode-ui/src/services/contextBroker.ts` — the existing postMessage channel between neode-ui and AIUI; if AIUI honours a message-based control rather than query parameters, this is where that would ride.
- `.planning/phases/02-ui-performance/02-RESEARCH.md` open question 1 — the full statement of what is known and unknown about AIUI's location and parameter support.
</read_first>
<action>
Settle where AIUI's source is and what it accepts, and write the answer down before
any code is written.
Search for the checkout: check `../../AIUI` relative to `neode-ui` (that is,
`<parent-of-archy>/AIUI`), then search the filesystem more broadly for a directory
containing AIUI's own `package.json`. Project notes record the ThinkPad at `.116` as
the primary build server, so if the source is not on this machine it may be there —
check whether it is reachable before concluding it is unavailable.
If the source is found, grep it for how it reads embed configuration: search for the
existing parameters `embedded`, `hideClose`, `mockArchy` and `seed` to find the
parameter-parsing site, then determine whether anything already controls (a) the
chat's expanded versus collapsed initial state and (b) the initial view on a mobile
viewport (chat versus context). Record the exact parameter names, accepted values,
and the file and line where each is read.
If the source is not found, record that plainly with the paths searched. Do not
invent a parameter name and ship it — a flag nothing reads is a change that looks
done and does nothing.
Also inspect the running container path: `apps/aiui/manifest.yml` points at a prebuilt
image, so an AIUI-side change requires a rebuild and republish of that image. Record
what shipping an AIUI-side change would actually involve, because D-15 restricts this
phase to the dev pair with no OTA.
Write `.planning/phases/02-ui-performance/02-AIUI-D14.md` recording: the source
location (or the searched paths and the conclusion); the embed-parameter contract as
it exists today; for each of D-14's two defaults, whether it is already supported,
needs a new AIUI-side parameter, or needs a postMessage control; which side implements
each half; and what deploying the AIUI half requires. Commit it.
This document is the contract Task 2 builds against and is the artifact a future agent
reads instead of re-running this search.
</action>
<verify>
<automated>D=/home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-AIUI-D14.md; test -f "$D" || exit 1; for s in '## Source Location' '## Embed Parameter Contract' '## D-14a' '## D-14b' '## Deployment Impact'; do grep -qF "$s" "$D" || { echo "missing: $s"; exit 1; }; done; echo OK</automated>
</verify>
<acceptance_criteria>
- `.planning/phases/02-ui-performance/02-AIUI-D14.md` exists and contains all five required headings
- `## Source Location` states either an absolute path to the AIUI checkout or the list of paths searched and the conclusion that it is unreachable
- `## Embed Parameter Contract` lists every embed parameter AIUI reads today with the file and line where each is parsed, or states that the parsing site could not be inspected and why
- `## D-14a` and `## D-14b` each state one of: already supported by parameter X, needs a new AIUI-side parameter, or needs a postMessage control — and name which side implements it
- `## Deployment Impact` states what shipping the AIUI half requires, given that `apps/aiui/manifest.yml` points at a prebuilt image and D-15 limits this phase to the dev pair with no OTA
- No parameter name appears in `neode-ui/src/views/Chat.vue` at the end of this task: `git diff --name-only HEAD -- neode-ui/src/views/Chat.vue | wc -l` prints 0
- The document is committed
</acceptance_criteria>
<done>Where AIUI lives, what it accepts, which side implements each D-14 default, and what deploying it costs are all written down and committed — or the search is documented as exhausted so a human can point at the checkout.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Stable embed URL carrying both D-14 defaults</name>
<precondition>`.planning/phases/02-ui-performance/02-AIUI-D14.md` exists, is committed, and its `## Source Location` section names a reachable AIUI checkout rather than recording the search as exhausted (Task 1 outcome)</precondition>
<files>neode-ui/src/views/Chat.vue, neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts</files>
<read_first>
- `.planning/phases/02-ui-performance/02-AIUI-D14.md` — the contract written by Task 1; it names the parameters and which side implements each default
- `neode-ui/src/views/Chat.vue` lines 61-125 — the `aiuiUrl` computed, the `onAiuiMessage` origin check, and the `onActivated` / `onDeactivated` handling added by plan 02-04
- `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — the bucket table for `Chat.vue`, so this task does not undo the placements made there
- `neode-ui/src/services/contextBroker.ts` — if Task 1 concluded a postMessage control is needed instead of a query parameter, this is the channel it rides
- `neode-ui/src/composables/useDemoIntro.ts``IS_DEMO` is already a build-time-ish input to `aiuiUrl`; confirm whether it can change at runtime before treating it as stable
</read_first>
<behavior>
- `aiuiUrl` returns the same string across re-renders of a mounted Chat view, including after a deactivate and reactivate cycle
- `aiuiUrl` does not change when the viewport is resized across the mobile breakpoint
- The returned URL carries the D-14a expanded-state flag and the D-14b mobile-initial-view flag exactly as named in `02-AIUI-D14.md`
- The existing `embedded` and `hideClose` parameters and the demo-mode parameters are unchanged
- `onAiuiMessage` still rejects a message whose origin differs from the embed URL's origin
- `aiuiConnected` remains true across a deactivate and reactivate cycle, since AIUI's ready message is not re-sent
</behavior>
<action>
Implement the neode-ui half of D-14 using exactly the parameter names and values
recorded in `02-AIUI-D14.md`. Append them in the existing `aiuiUrl` computed,
following the string-concatenation convention already there, for both the
`VITE_AIUI_URL` branch and the production/demo branch.
The load-bearing constraint is URL stability. The Chat view's instance now survives
tab switches, and the iframe only keeps its loaded state while its `src` stays
byte-identical. Any input to `aiuiUrl` that can change at runtime — a reactive
viewport width, a connection flag, a timestamp, a random value — would change the
`src`, force a full AIUI reload on the next render, and give back the entire benefit
of keeping the tab alive. So D-14b's mobile default must not be expressed as a
reactive viewport read in this computed. Pass a mobile-initial-view flag whose value
is fixed for the view instance and let AIUI decide from its own viewport, or resolve
the viewport once at setup time into a non-reactive constant. Record which of the two
you chose and why in the SUMMARY.
Do not change the origin-validation logic in `onAiuiMessage`. These are presentation
flags; nothing here widens what the embedded application may reach, and the origin
check is what keeps that true.
Do not reset `aiuiConnected` on deactivate. AIUI sends its `ready` message once after
load; a reset would leave the panel showing a disconnected state forever after the
first tab switch. Plan 02-04 already flagged this — confirm it holds.
If `02-AIUI-D14.md` records that a default is implemented on the AIUI side, make that
change in the AIUI checkout too, keeping it as small as the default itself: change the
initial state, do not restructure AIUI's layout. Commit it in that repository and
record the commit reference in this plan's SUMMARY. Deploying it follows D-15 — dev
pair only, no OTA — and plan 02-08 owns the deploy.
Create `neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts` covering the six behaviors
above. The stability assertions are the important ones: read `aiuiUrl` twice across a
simulated resize and across a deactivate/reactivate cycle and assert string equality.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/__tests__/chatAiuiEmbed.test.ts && npm run test && npm run type-check && npm run build</automated>
</verify>
<acceptance_criteria>
- `npm run test -- src/views/__tests__/chatAiuiEmbed.test.ts` exits 0 with all six behaviors covered
- A test asserts `aiuiUrl` is string-equal before and after a simulated viewport resize across the mobile breakpoint
- A test asserts `aiuiUrl` is string-equal before and after a deactivate/reactivate cycle
- A test asserts the returned URL contains both D-14 flag names exactly as recorded in `02-AIUI-D14.md`, plus the pre-existing `embedded=true` and `hideClose=true`
- A test asserts a message from a foreign origin does not set `aiuiConnected`
- A test asserts `aiuiConnected` survives a deactivate/reactivate cycle
- `npm run test` exits 0, `npm run type-check` exits 0, `npm run build` exits 0
- The built bundle carries the new flags: `grep -rl "hideClose" web/dist/neode-ui/assets | head -1` prints a file, and the same file also matches the D-14 flag names
- If an AIUI-side change was required, the SUMMARY records its repository path and commit reference
</acceptance_criteria>
<done>The AIUI panel opens expanded, opens on chat on mobile, and its embed URL is provably stable across resize and reactivation so the iframe never reloads on a tab switch.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Confirm the AIUI panel persists and both D-14 defaults hold on desktop and mobile</name>
<what-built>
The Chat tab's AIUI panel now survives tab switches without reloading, and the two
D-14 defaults are in place: the chat opens expanded, and on a mobile viewport AIUI
opens on its chat view rather than its context view. The embed URL is proven stable
across viewport resize and across deactivate/reactivate, which is what keeps the
iframe from reloading.
</what-built>
<how-to-verify>
1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview
pointed at archi-dev (password `password123`).
2. Open the Chat tab and wait for the AIUI panel to finish loading.
3. Expected: the chat is already expanded — you should not have to expand it yourself.
4. Switch to another main tab, then back to Chat. Expected: the panel is still loaded
exactly as you left it, including anything you typed. No loading spinner, no
flash, no scroll back to the top of the conversation.
5. Resize the browser window across the mobile breakpoint while on the Chat tab.
Expected: the panel does not reload.
6. In a mobile viewport (device toolbar, or on a phone against the same preview), open
the Chat tab fresh. Expected: it opens on the CHAT view, not on the context view.
7. Switch away and back on mobile. Expected: still on chat, still loaded, still
expanded.
8. Confirm the AIUI panel still functions — send a message and get a response — so the
presentation flags did not disturb the connection.
</how-to-verify>
<resume-signal>Type "approved", or describe what you saw: which step, desktop or mobile, what happened instead.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| neode-ui → embedded AIUI iframe | Configuration crosses out of the trusted app into an embedded application via the URL query string |
| embedded AIUI iframe → neode-ui | AIUI posts messages back into the host window; only same-origin-as-the-embed-URL messages may be honoured |
| AIUI container image → fleet nodes | An AIUI-side change ships as a rebuilt prebuilt image, not as a neode-ui asset |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-05 | Spoofing | `onAiuiMessage` origin validation in `Chat.vue` | medium | mitigate | Task 2 forbids touching the origin check and requires a test asserting a foreign-origin message does not set `aiuiConnected`; the flags added are presentation-only |
| T-02-18 | Elevation of Privilege | Embed flags widening what the iframe is trusted with | high | mitigate | This plan's second prohibition scopes the change to how AIUI opens, never what it may reach. No permission, token, credential or capability parameter is added; only initial-view and expanded-state flags recorded in `02-AIUI-D14.md` |
| T-02-19 | Information Disclosure | Sensitive values leaking into an iframe URL, which appears in referrer headers and browser history | high | mitigate | The two flags are boolean-shaped presentation values. Task 1's contract document is the review point: if a proposed parameter carries anything identity- or session-bearing, it must not ship in the query string |
| T-02-20 | Tampering | An AIUI-side change reaching the fleet outside the release train | medium | mitigate | D-15 restricts this phase to the dev pair with no OTA; Task 1 records what shipping the AIUI half requires and plan 02-08 owns the deploy under that constraint |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope on the neode-ui side. If the AIUI checkout requires an install to build, that install runs in AIUI's own repository under its own lockfile; do not add a dependency to `neode-ui/package.json` in this plan |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Created or changed by this plan — new API, not drift:
- `.planning/phases/02-ui-performance/02-AIUI-D14.md` — the recorded AIUI source location and embed-parameter contract
- `neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts`
- Two new embed query parameters on the AIUI iframe URL, named in `02-AIUI-D14.md` (D-14a expanded state, D-14b mobile initial view)
- Possibly a corresponding change in the AIUI sibling repository, referenced by commit in the SUMMARY
Created elsewhere in Phase 02: `shouldKeepAlive()`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`,
`DashboardRouterView.vue`, `RefreshIndicator.vue`, `resources.clearAll()`,
`useCachedResource.test.ts`, `keepAliveTabs.test.ts`, `keepAliveLifecycle.test.ts`,
`meshTabCache.test.ts`, `serverTabCache.test.ts`, `homeTabCache.test.ts`,
`secondaryScreenCache.test.ts`, `resourcesClear.test.ts`,
`e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped; surfaced here for human review. Resolved in substance by this plan's `must_haves.truths`, with the AIUI-layout-regression truth carried as a `verification: backstop` marker because it depends on an application whose source is outside this repository.
- **FA-E (RESEARCH open question 1, unresolved at plan time):** AIUI's source is not present in this checkout — `neode-ui`'s `dev:mock` script and `scripts/setup-aiui-server.sh` both expect it at `../../AIUI`, and RESEARCH.md verified no such directory exists on the machine that ran the research. Project notes record the ThinkPad at `.116` as the primary build server, so it may live there. This is a genuine missing-information constraint, not a difficulty judgment. Task 1 resolves it and Task 2 carries a `<precondition>` that halts if it could not be resolved — D-14 is a locked decision and is not deferred or reduced, it is blocked on a fact only the developer can supply.
- **Open:** whether either D-14 default is already supported by an existing AIUI parameter is unknown. `Chat.vue` already passes `embedded`, `hideClose`, `mockArchy` and `seed`, so a parameter mechanism exists; whether it covers expanded-state and mobile-initial-view is what Task 1 determines.
- **Open:** whether D-14b is better expressed as a fixed flag AIUI interprets against its own viewport, or as a viewport resolved once at setup, is decided in Task 2 and recorded. Both satisfy the URL-stability constraint; a reactive viewport read does not.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0 and the D-14 flags appear in `web/dist/neode-ui/assets`
- `.planning/phases/02-ui-performance/02-AIUI-D14.md` is committed with all five headings
- The human-verify checkpoint is approved on both desktop and mobile viewports
</verification>
<success_criteria>
- The AIUI panel survives tab switches with no reload, proven by a string-equality test on the embed URL and confirmed by eye
- The chat opens expanded and, on mobile, opens on the chat view
- The embed URL contains no runtime-varying value
- Origin validation is unchanged and the connected state survives deactivation
- The AIUI-side contract is written down, so no future agent repeats the search
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-07-SUMMARY.md` when done. It MUST record:
the AIUI source location; the exact parameter names and values shipped; which side
implements each D-14 default and, if AIUI-side, the repository path and commit reference;
how D-14b was expressed without introducing a runtime-varying URL input; and what
deploying the AIUI half requires under D-15.
</output>

View File

@ -0,0 +1,343 @@
---
phase: 02-ui-performance
plan: 08
type: execute
wave: 5
depends_on: ["02-03", "02-05", "02-06", "02-07"]
files_modified:
- .planning/phases/02-ui-performance/02-PERF-AFTER.json
- .planning/phases/02-ui-performance/02-FINDINGS.md
- neode-ui/src/views/dashboard/keepAliveRoutes.ts
autonomous: false
requirements: [PERF-01, PERF-02, PERF-03]
must_haves:
truths:
- "The same harness that produced the baseline is re-run against archi-dev-box and produces a directly comparable after-artifact"
- "For every surface the findings doc named as slow, the after-artifact shows a lower revisit time and a lower revisit RPC count than the baseline"
- "For every main tab registered for instance caching, the after-artifact's remount probe shows the component instance survived a tab round-trip"
- "Revisiting any main tab already visited this session on archi-dev-box shows no spinner and no blank screen (D-11 pass bar)"
- "Reopening any secondary screen already opened this session on archi-dev-box shows no blocking reload (D-11 pass bar)"
- "The instance-cache cap is set from observed on-device memory rather than from an estimate, and the observation is recorded"
- "The build shipped to the dev pair actually contains this phase's changes, confirmed by grepping the built bundle"
- "The deploy reached the dev pair only — no fleet node and no OTA channel received this build (D-15)"
- "A surface that regressed against its baseline is recorded as a regression rather than averaged away"
- statement: "Extended use across many tab visits on archi-dev-box does not reintroduce sluggishness — memory and idle CPU stay flat"
verification: backstop
prohibitions:
- "MUST NOT present inferred, code-read, or cherry-picked numbers as measured profiling results, and MUST NOT omit a surface from the results because it was hard to measure — an unmeasured surface is recorded as unmeasured, never as improved"
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch a surface needs, no disabling a feature to win the metric"
- "MUST NOT push this phase's build beyond the dev pair — no fleet node, no OTA channel, no alpha-tester deploy path"
artifacts:
- path: ".planning/phases/02-ui-performance/02-PERF-AFTER.json"
provides: "Post-fix measurements from the same harness and the same target as the baseline"
- path: ".planning/phases/02-ui-performance/02-FINDINGS.md"
provides: "A Results section comparing baseline to after, per surface, including any regression"
key_links:
- from: ".planning/phases/02-ui-performance/02-PERF-AFTER.json"
to: "neode-ui/e2e/perf/surface-perf.spec.ts"
via: "produced by re-running the plan 02-01 harness unmodified against the same target"
pattern: "surface-perf"
- from: ".planning/phases/02-ui-performance/02-FINDINGS.md"
to: ".planning/phases/02-ui-performance/02-PERF-BASELINE.json"
via: "the Results section pairs each after row with its baseline row"
pattern: "02-PERF-BASELINE"
---
<objective>
Deploy this phase's frontend to the dev pair, re-measure every surface on archi-dev-box
with the same harness that produced the baseline, and walk the D-11 pass bar by hand.
Purpose: PERF-01 closes the loop it opened — the same instrument, the same target, before
and after. PERF-02 and PERF-03 are both stated in terms of what the user perceives on real
node hardware, and CONTEXT.md D-11 makes archi-dev-box the verification target with an
explicit pass bar: no visible spinner or blank on revisit of a tab or secondary screen
already visited this session; first visits may still show loading. D-15 keeps this to the
dev pair — no OTA, no fleet.
Output: a committed after-artifact, a per-surface before/after comparison including any
regression, an instance-cache cap set from observed memory, and a human-confirmed pass bar
on the node.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/phases/02-ui-performance/02-02-SUMMARY.md
@.planning/phases/02-ui-performance/02-03-SUMMARY.md
@.planning/phases/02-ui-performance/02-04-SUMMARY.md
@.planning/phases/02-ui-performance/02-05-SUMMARY.md
@.planning/phases/02-ui-performance/02-06-SUMMARY.md
@.planning/phases/02-ui-performance/02-07-SUMMARY.md
@CLAUDE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Build and deploy the frontend to the dev pair only</name>
<precondition>archi-dev-box resolves and answers over HTTP from this machine, and `scripts/deploy-config.sh` exists (it is gitignored; `scripts/deploy-config.example` documents it) so the deploy script can authenticate</precondition>
<files>neode-ui/src/views/dashboard/keepAliveRoutes.ts</files>
<read_first>
- `CLAUDE.md` — the build note: `neode-ui/` builds to `web/dist/neode-ui/`, and the built bundle must be grepped for new strings before shipping because the build can silently no-op. Also the commit-and-push-every-unit-of-work rule.
- `scripts/deploy-to-target.sh` lines 1-30 — the usage block. `--frontend-only` skips the Rust build and container rebuilds; `--live` targets the default host; `--both` fans out to additional hosts; `--tailscale` reaches the alpha-tester nodes.
- `scripts/deploy-config.example` — what `deploy-config.sh` must contain.
- `.planning/PROJECT.md` — the deploy-to-the-dev-pair-before-any-OTA rule.
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-15 restricts this phase to the dev pair with no OTA, and D-11 names archi-dev-box as the verification target.
- `.planning/phases/02-ui-performance/02-0{2,3,4,5,6,7}-SUMMARY.md` — the list of new symbols and cache keys to grep the built bundle for.
</read_first>
<action>
Run the full check suite from `neode-ui/` first: `npm run type-check`, then
`npm run test`, then `npm run build`. All three must be green before anything ships.
Then confirm the build is real, not a silent no-op. CLAUDE.md warns about exactly this.
Grep `web/dist/neode-ui/` for a representative string introduced by each plan in this
phase — the `shouldKeepAlive` classifier, the `RefreshIndicator`, a cache key such as
`app-catalog` or `app-details:`, and the D-14 flag names recorded in
`02-AIUI-D14.md`. Collect the exact strings from the plan SUMMARYs rather than
guessing them. If any is absent, the build did not take — clean and rebuild before
deploying, and record what happened.
Deploy the frontend to the dev pair with `scripts/deploy-to-target.sh
--frontend-only`, targeted at the dev pair hosts only. Read the script's usage block
and its host configuration to identify which flag combination reaches exactly the dev
pair. Do not use `--tailscale` or `--tailscale-node` — those reach alpha-tester fleet
nodes. Do not trigger any OTA or release path. D-15 is explicit and this plan's third
prohibition restates it. Record the exact command run and the hosts it touched.
After deploying, tune the instance-cache cap. `KEEP_ALIVE_MAX` has been 6 since plan
02-02 on the reasoning that it is smaller than the main-tab count so the long tail
evicts, which was never validated against real hardware. On archi-dev-box, open the
UI, cycle through every main tab twice including Mesh, and read the browser's memory
usage before and after. If resident memory grows in a way that would matter on a
low-power fleet node, lower the cap; if it is comfortably flat and evictions are
causing visible reloads of tabs the user is actively cycling, raise it. Change the
constant only if the observation calls for it, commit the change, and record the
measurement either way — an unchanged 6 with a recorded memory reading is a valid and
preferable outcome to an unexamined 6.
Commit and push each unit of work as it lands, per CLAUDE.md, staging explicitly by
path.
</action>
<verify>
<automated>cd neode-ui && npm run type-check && npm run test && npm run build && for s in shouldKeepAlive RefreshIndicator app-catalog; do grep -rqs "$s" ../web/dist/neode-ui/ || { echo "MISSING FROM BUNDLE: $s"; exit 1; }; done; echo BUNDLE_OK</automated>
</verify>
<acceptance_criteria>
- `npm run type-check`, `npm run test` and `npm run build` all exit 0
- `web/dist/neode-ui/` contains `shouldKeepAlive`, `RefreshIndicator` and at least one cache key introduced by this phase
- The deploy command actually run is recorded verbatim in the SUMMARY, along with every host it touched
- No alpha-tester or fleet host appears in that host list; no OTA or release path was invoked
- `KEEP_ALIVE_MAX`'s value at the end of this task is recorded together with the on-device memory reading that justifies it
- Every change is committed and pushed, staged by explicit path
</acceptance_criteria>
<done>A verified-real build is running on the dev pair and nowhere else, and the instance-cache cap is set from an observed memory reading rather than an estimate.</done>
</task>
<task type="auto">
<name>Task 2: Re-measure on archi-dev-box and write the before/after comparison</name>
<precondition>The dev-pair deploy from Task 1 is live — the archi-dev-box UI serves the new bundle (grep the served asset for `shouldKeepAlive`, not just the local `web/dist` copy)</precondition>
<files>.planning/phases/02-ui-performance/02-PERF-AFTER.json, .planning/phases/02-ui-performance/02-FINDINGS.md</files>
<read_first>
- `neode-ui/e2e/perf/surface-perf.spec.ts`, `neode-ui/e2e/perf/measure.ts`, `neode-ui/e2e/perf/surfaces.ts` — the harness from plan 02-01 and its `ARCHY_BASE_URL` / `ARCHY_PERF_OUT` contract
- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` — the run header records the exact target and sample count the after-run must match
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the per-surface table and ranked fix order this task extends with results
- `.planning/phases/02-ui-performance/02-04-SUMMARY.md` — which main-tab paths ended up registered for instance caching, so the remount-probe expectation is known per surface
- `.planning/phases/02-ui-performance/02-03-SUMMARY.md` — which secondary screens were converted and which were reported as gaps
</read_first>
<action>
Re-run the plan 02-01 harness unmodified against archi-dev-box, with the same
`ARCHY_BASE_URL` and the same sample count recorded in the baseline's run header, and
`ARCHY_PERF_OUT` pointed at
`.planning/phases/02-ui-performance/02-PERF-AFTER.json`. Do not edit the harness to
make numbers look better; if a selector genuinely broke because a view's markup
changed, fix the selector, re-run BOTH the baseline target and the after target so the
pair stays comparable, and say so in the run header.
Append a `## Results` section to `02-FINDINGS.md` with a table pairing each surface's
baseline and after rows: Surface, Baseline revisit ms, After revisit ms, Baseline
revisit RPC count, After revisit RPC count, Baseline remounted, After remounted,
Verdict. `Verdict` takes one of `improved`, `unchanged`, `regressed` or `unmeasured`.
Three rules govern that table and none of them may be softened:
- A surface that got worse is recorded as `regressed` with its numbers. Do not average
it into an aggregate, do not re-run until it looks better, do not drop it.
- A surface that could not be measured is `unmeasured` with the reason. It is never
recorded as `improved`.
- Every number comes from the artifacts. No number is estimated, inferred from reading
the code, or taken from the best of several runs.
Add a `## Outstanding` subsection listing anything still open: surfaces still
classified `unmeasured`, any secondary screen plan 02-03 reported as a gap rather than
converting, any `regressed` verdict, and any assumption from the plan set's
`Assumptions & Flagged Items` blocks that execution did not settle. This list is what
`/gsd-verify-work` and any follow-up gap-closure planning read.
Redact before committing, as in plan 02-01: onion addresses, DIDs, pubkeys, wallet
figures, peer hostnames and file names do not go into the artifacts. RPC method names
and timings do.
</action>
<verify>
<automated>node -e "const p='/home/archipelago/Projects/archy/.planning/phases/02-ui-performance/'; const a=require(p+'02-PERF-AFTER.json'), b=require(p+'02-PERF-BASELINE.json'); const ar=a.results??a, br=b.results??b; if(!Array.isArray(ar)||ar.length!==br.length){console.error('row count mismatch',ar.length,br.length);process.exit(1)} console.log('rows',ar.length)" && grep -qF '## Results' /home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-FINDINGS.md && grep -qF '## Outstanding' /home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-FINDINGS.md && echo OK</automated>
</verify>
<acceptance_criteria>
- `.planning/phases/02-ui-performance/02-PERF-AFTER.json` exists, parses, and has the same row count as the baseline
- Its run header records the same `baseUrl` and `runs` as the baseline run header, or explains any difference
- `02-FINDINGS.md` contains `## Results` with one row per surface and `## Outstanding`
- Every Results row's Verdict is one of `improved`, `unchanged`, `regressed`, `unmeasured`
- Every surface the findings originally named as slow has a numeric after value, or a recorded reason it is `unmeasured`
- Every main-tab path registered in `KEEP_ALIVE_PATHS` has `After remounted` false
- `## Outstanding` lists every regressed surface, every unmeasured surface, every gap reported by plan 02-03, and every unsettled flagged assumption
- Both artifacts are committed
</acceptance_criteria>
<done>The same instrument on the same target says, in committed numbers, what this phase actually changed per surface — including anything that got worse or could not be measured.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: D-11 pass bar on archi-dev-box — is the sluggishness gone on-device</name>
<what-built>
The whole phase, running on archi-dev-box: main tabs instance-cached with capped
eviction and background revalidation, secondary screens cached per item, the Mesh
graph and map held for the session, Server and Home fetches cached with the wallet
always re-checking on re-entry, and the AIUI panel persisting across tab switches with
its two D-14 defaults. Automated before/after numbers are already committed in
`02-FINDINGS.md` `## Results`.
</what-built>
<how-to-verify>
This is the D-11 pass bar. The bar is: no visible spinner and no blank screen when
revisiting a tab or secondary screen already visited this session. First visits may
still show loading — that is allowed.
1. Open archi-dev-box's UI directly on the node (not the local dev preview) so you are
exercising the deployed build on real hardware.
2. First pass — visit every main tab once, in order: Home, Apps, App store, Cloud,
Mesh, Server, Web5, Fleet, Chat, Settings. Loading here is expected.
3. Second pass — revisit each of those tabs in a different order. Expected on every
one: content appears immediately, no spinner, no blank frame, scroll position and
in-page state preserved, no intro animation replay.
4. Open at least three secondary screens: an app's detail page, a cloud folder, and
one more of your choosing. Go back to the parent tab and reopen each. Expected on
reopen: content appears immediately with no blocking reload.
5. The app store specifically — this is the surface you reported as worst. Switch into
it and out of it several times. Expected: it should feel immediate every time after
the first.
6. Home wallet — note the balance, go away for a minute, come back. Expected: the
figure is there instantly and visibly re-checks (a small indicator, then the
current number). It must not sit frozen.
7. Mesh — enter, let the graph settle, leave, return. Expected: the graph is where you
left it, the map draws correctly, and the layout does not re-animate from scratch.
8. Chat — the AIUI panel should already be expanded, and should still be loaded after
switching away and back.
9. Extended use — keep using the node normally for several minutes, cycling tabs.
Expected: it stays fast. If sluggishness creeps back after extended use, say so and
name the tabs you had visited — that points at the instance-cache cap.
10. Compare against how it felt before this phase. The question that decides this
checkpoint: is the sluggishness you reported gone on this device?
</how-to-verify>
<resume-signal>Type "approved" if the pass bar is met, or describe what you saw: which step, which surface, what happened instead.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer workstation → dev-pair nodes | A build crosses onto running hardware via the deploy script |
| dev pair → fleet / OTA channel | The boundary D-15 forbids crossing in this phase |
| archi-dev-box measurements → committed artifacts | Node data crosses into a pushed repository |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-21 | Elevation of Privilege | `scripts/deploy-to-target.sh` reaching fleet or alpha-tester nodes | high | mitigate | Task 1 requires `--frontend-only` targeted at the dev pair, explicitly forbids `--tailscale` and `--tailscale-node`, forbids any OTA or release path, and requires the exact command and host list to be recorded in the SUMMARY for audit |
| T-02-06 | Information Disclosure | `02-PERF-AFTER.json` and `02-FINDINGS.md` committed to a pushed repo | medium | mitigate | Task 2 carries the same redaction rule as plan 02-01: RPC method names and timings only, no addresses, identities, balances or file names |
| T-02-22 | Repudiation | An unverified build shipped and later believed to contain this phase's changes | medium | mitigate | Task 1 greps the built bundle for a representative string from every plan in the phase before deploying, per the CLAUDE.md silent-no-op warning, and Task 2's precondition re-greps the asset actually served by the node |
| T-02-03 | Denial of Service | Instance-cache memory on low-power hardware | medium | mitigate | Task 1 sets `KEEP_ALIVE_MAX` from an on-device memory reading and records the reading; Task 3 step 9 exercises extended use to surface any residual growth |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope; this plan builds and deploys existing code. A task that finds it needs a new dependency stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Created or changed by this plan — new API, not drift:
- `.planning/phases/02-ui-performance/02-PERF-AFTER.json`
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — gains `## Results` and `## Outstanding`
- `KEEP_ALIVE_MAX` — value finalised from an on-device memory reading
Full phase inventory (for the source-grounding pass): `neode-ui/e2e/perf/surfaces.ts`
(`SURFACES`, `Surface`), `neode-ui/e2e/perf/measure.ts` (`measureSurface`,
`SurfaceMeasurement`, `RpcCall`), `neode-ui/e2e/perf/surface-perf.spec.ts`,
`neode-ui/src/views/dashboard/keepAliveRoutes.ts` (`shouldKeepAlive`, `KEEP_ALIVE_PATHS`,
`KEEP_ALIVE_MAX`), `neode-ui/src/views/dashboard/DashboardRouterView.vue`
(`isFullBleedRoute`, `wrapperClass`, `wrapperStyle`),
`neode-ui/src/components/RefreshIndicator.vue`, `resources.clearAll()`, `TAB_ORDER`
(promoted to an export of `useRouteTransitions.ts`),
`neode-ui/src/composables/__tests__/useCachedResource.test.ts`,
`neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts`,
`neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts`,
`neode-ui/src/views/__tests__/{secondaryScreenCache,meshTabCache,serverTabCache,homeTabCache,chatAiuiEmbed}.test.ts`,
`neode-ui/src/stores/__tests__/resourcesClear.test.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json,02-AIUI-D14.md}`,
cache keys `app-catalog`, `bitcoin.prune-status`, `app-details:<dataset>:<id>` and the
per-group Mesh, Server and Home keys recorded in their plan SUMMARYs, environment variable
`ARCHY_PERF_OUT`, and the two AIUI embed query parameters named in `02-AIUI-D14.md`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
### Edge-coverage probe rows (spec-less fallback — all three unclassified/unresolved)
| Requirement | Probe status | Disposition here |
|---|---|---|
| PERF-01 | `unclassified` / `unresolved` | FLAGGED, not auto-backstopped, not dropped. Resolved in substance by this plan's before/after comparison truths and by plan 02-01's measurement truths. The probe row itself stays unresolved and is surfaced for human review. |
| PERF-02 | `unclassified` / `unresolved` | FLAGGED. Resolved in substance by the D-11 pass-bar truths here and by the per-tab truths in plans 02-02, 02-04, 02-05, 02-06 and 02-07. |
| PERF-03 | `unclassified` / `unresolved` | FLAGGED. Resolved in substance by the secondary-screen pass-bar truth here and by the per-item cache truths in plan 02-03. |
The extended-use stability truth is carried as a `verification: backstop` marker: it is a
perceptual and long-running property that neither the unit suite nor a single harness run
can confirm, so it abstains to human review rather than passing silently.
### Carried assumptions this plan is the last chance to settle
- **FA-D (`KEEP_ALIVE_MAX`):** carried at 6 through plans 02-02 to 02-07 on reasoning alone. Task 1 replaces the estimate with an on-device memory reading. If the reading is not taken, the assumption stays open and belongs in `## Outstanding`.
- **FA-B (`ContainerAppDetails.vue`):** plan 02-01 records the reachability verdict and plan 02-03 excludes the file. If the verdict is that it is dead code, note in `## Outstanding` that removing it is a candidate for a follow-up cleanup, not part of this phase.
- **FA-E (AIUI source):** if plan 02-07's Task 2 precondition halted, D-14 is incomplete and belongs in `## Outstanding` as a blocked locked decision — not as a deferred one.
- **Scope gaps from plan 02-03:** any secondary screen the findings named that plan 02-03 reported rather than converted is listed in `## Outstanding` with its measured cause.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run type-check && npm run test && npm run build` all exit 0
- The built bundle contains this phase's new symbols
- The deploy touched dev-pair hosts only, recorded verbatim in the SUMMARY
- `02-PERF-AFTER.json` has the same row count as `02-PERF-BASELINE.json`
- `02-FINDINGS.md` contains `## Results` and `## Outstanding`
- The D-11 pass-bar checkpoint is approved on archi-dev-box
</verification>
<success_criteria>
- Every surface the findings named as slow has a committed after-number from the same instrument and the same target
- Every registered main tab shows a surviving instance in the after-artifact's remount probe
- Revisits to tabs and secondary screens on archi-dev-box show no spinner and no blank screen
- Any regression or unmeasured surface is recorded as such, not smoothed over
- The instance-cache cap is justified by an observed memory reading
- The build reached the dev pair and nothing else
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-08-SUMMARY.md` when done. It MUST record:
the exact deploy command and every host it touched; the on-device memory reading and the
final `KEEP_ALIVE_MAX`; the per-surface before/after verdicts; the full `## Outstanding`
list; and the human verdict on the D-11 pass bar in the user's own words.
</output>