Task 2 of 02-04 — finishes the remaining tabs' lifecycle audit (Apps.vue,
Discover.vue) and widens registration from the 02-02 tracer's single seed
path to every main tab the profiling pass showed remounting.
- Apps.vue: the 15s "unable to connect" timer follows activate/deactivate
(idempotent re-arm, cleared on exit) and resets connectionError on entry so
a since-reconnected node doesn't show a stale error instantly; the intro
flag stays once-per-session.
- Discover.vue: loadCommunityMarketplace/loadBitcoinPruneStatus now route
through the same shared 'app-catalog'/'bitcoin.prune-status' cache keys
Marketplace.vue introduced in 02-02, rather than duplicating the fetch;
RefreshIndicator wired to the catalog resource's loadState. Discover's own
dynamic-catalog-first fetcher (fetchAppCatalog with a curated-list
fallback) is preserved as this key's fetcher for this view — both views
are valid producers of the same shared cache entry.
- Fleet.vue: confirmed no lifecycle side effects (grep for the five tokens
found none) — left unchanged, registered as-is.
- keepAliveRoutes.ts: KEEP_ALIVE_PATHS now derives from TAB_ORDER (single
source of truth) plus /dashboard/discover, deliberately withholding
/dashboard/settings even though it's in TAB_ORDER — Settings.vue's child
sections (SystemDangerZone's reboot poll interval,
VpnStatusSection/KioskDisplaySection/TransportPrefsCard/ClaudeAuthSection's
one-shot onMounted fetches) were never in this plan's file scope and would
misbehave under KeepAlive exactly as this plan exists to prevent. Every
other TAB_ORDER path measured Remounted:true or was unmeasured in
02-FINDINGS.md, so per the plan's literal exclusion rule (only a measured
Remounted:false excludes) they all stay registered, including Mesh and
Chat.
- keepAliveLifecycle.test.ts extended (in the prior commit) with
shouldKeepAlive true/false assertions across every registered path and six
secondary-screen paths, plus the KEEP_ALIVE_MAX+2 eviction test against the
real DashboardRouterView + KEEP_ALIVE_PATHS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 of 02-04 — audits every side effect owned by Home.vue, web5/Web5.vue,
Chat.vue, Cloud.vue, Server.vue and Mesh.vue and places each into one of
three buckets (once-per-session, every-entry, only-while-visible) so their
instances are safe to keep alive once KEEP_ALIVE_PATHS widens in Task 2.
- Home.vue: systemStats/wallet polling, the wsClient wallet-push
subscription and its debounce timer follow activate/deactivate with an
immediate re-sync on entry; hydrateWalletSnapshot/checkUpdateStatus/cloud
usage stay once-per-session.
- Chat.vue: the window `message` listener and ContextBroker follow
activate/deactivate; aiuiConnected is never reset on deactivate since the
iframe's one-time 'ready' message won't resend on re-entry.
- Web5.vue: the six child-component data loaders (none use
useCachedResource internally) and the 30s LND poll move to
activate/deactivate; the DID lookup and intro flag stay once-per-session.
- Cloud.vue: the per-peer transport/reachability warm-cache
(loadPeerFiles/loadCounts/loadPeers) re-runs every entry — the one path
here that bypasses useCachedResource and would otherwise render stale peer
reachability (T-02-13).
- Server.vue: the previously module-scope-armed 15s VPN poll interval now
follows activate/deactivate (it used to run forever regardless of
visibility); loadDiskStatus becomes every-entry.
- Mesh.vue: the entire live-communications surface (window/document
listeners, the 5s/15s poll intervals, the ws peer-push subscription, and
the six-way federation/self/contacts refresh) follows activate/deactivate;
a share-to-mesh handoff via direct navigation is now correctly picked up
on every activation, not just the first mount.
- useCachedResource.ts: onActivated's staleness check now skips an
`immediate: false` resource that has never been explicitly fetched, so a
tab-gated lazy resource (Cloud.vue's Paid Files / My Files walk) isn't
eagerly force-loaded the moment its owning view is kept alive.
- Every arm/disarm pair is idempotent and duplicated into both onMounted and
onActivated, since onActivated is a no-op outside a KeepAlive boundary
(caught by CloudPeersRefresh.test.ts, which mounts Cloud.vue bare) —
fresh-mount guard flags avoid double-firing the heavier loaders
(Home/Mesh/Web5/Server) on a KeepAlive-wrapped first mount.
- New neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
covers the six lifecycle behaviors plus a real-view assertion
(Server.vue's VPN poll, mounted inside a real KeepAlive).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Task 3 checkpoint failed on the real preview: outer page margins broke
and the up/down main-tab slide animations stopped playing. Root cause: the
02-02 restructure moved view-wrapper (absolute inset-0) onto each view's
root inside the padded wrapper, and split navigation across two sibling
Transitions behind a stable intermediate div — but dashboard-styles.css
scopes every transition as a compound selector on .view-wrapper, which must
be the keyed direct child of .perspective-container.
- Restore the pre-02-02 rendered DOM exactly: single Transition whose child
is a keyed div.view-wrapper containing the per-route wrapper shape
- Re-integrate KeepAlive via statically-named per-route wrapper components
(dashboardViewWrappers.ts) so the keyed div.view-wrapper is the cached
component's own root; cache membership gated by :include on wrapper names
derived from KEEP_ALIVE_PATHS
- Drop the scroll-retention Map: scroll containers now live inside keyed/
cached wrappers, restoring the old reset-to-top behavior for non-kept routes
- Pin the visual contract structurally in keepAliveTabs.test.ts (padded
wrapper classes must render INSIDE div.view-wrapper)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- OpenWrtGateway.vue: manual resources-store usage (no TTL gating — every
mount force-refetched) replaced with useCachedResource (key
server.openwrt-status, no item id — one gateway per node, fixed route
with no :id param). load() keeps its explicit force-refresh semantics
for connect/tollgate actions via a pendingParams closure; onMounted now
only force-fetches when the cache is missing or past its 30s TTL.
- Fixed a real bug this conversion exposed (Rule 1): `loading` conflated
'refreshing' with 'loading', hiding the already-rendered status panels
behind the full skeleton on every background revalidation. Every mount
used to force a fetch, so this was previously masked — cached content
never got a chance to render before the skeleton took over. Now only a
true first-load (no data yet) blocks on the skeleton (D-07).
- secondaryScreenCache.test.ts: repeat-open call-count coverage for
OpenWrtGateway (within-TTL: one fetch; after TTL: cached paint + one
more fetch).
- CloudFolder.vue: left unchanged. See 02-03-SUMMARY.md for the
cache-placement decision and why a clean useCachedResource conversion
needs a cloud.ts change outside this plan's files_modified scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AppDetails.vue: bitcoin-sync and credentials converted to keyed
useCachedResource (app-details:bitcoin-sync:<id>, app-details:credentials:<id>),
each keyed by the route app id so two items never collide. Credentials
is persist:false (credential material, D-08/T-02-01). Both loaders stay
fire-and-forget from onMounted (already parallel — not touched). Stop/
restart/uninstall now invalidate() the credentials resource so a stale
healthy state can't outlive a destructive action (T-02-12).
- MarketplaceAppDetails.vue: the one RPC call in this view that isn't a
measurement artifact of the Home-tab-transit confound (package.versions)
is now a keyed useCachedResource (app-details:versions:<id>, 120s TTL —
near-static catalog metadata). getCurrentApp() is a sync store read and
the bitcoin-prune check is a plain fetch(), neither need conversion.
- secondaryScreenCache.test.ts: covers per-item isolation (rendered
content, not just call counts), TTL-gated no-refetch, TTL-lapse
revalidation, and keep-last-value on a rejected refresh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- resources.ts: clearAll() drops memory entries, in-flight/revalidator/
invalidate-timer bookkeeping, and every resource:-prefixed sessionStorage
key; a generation counter stops an in-flight fetch that resolves after
clearAll from repopulating memory or sessionStorage (T-02-02)
- auth.ts: logout() calls clearAll() in the finally path so a failed
server-side logout still leaves no cached payload behind locally
- resourcesClear.test.ts: covers all five required behaviors plus the
generation-guard fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Puts Marketplace.vue's data on the cache and adds the subtle background-
refresh indicator D-05 calls for:
- RefreshIndicator.vue: presentational, state-driven (ResourceLoadState).
Renders nothing for ready/idle/loading — a first load is the view's own
skeleton's job, not this component's — and a role="status"
aria-live="polite" spinner only while refreshing. A fixed-size outer slot
keeps its appearance/disappearance from ever shifting layout.
- Marketplace.vue: loadCommunityMarketplace()/loadBitcoinPruneStatus() move
onto useCachedResource behind shared keys app-catalog (300s TTL, persist —
near-static catalog, D-06 discretion) and bitcoin.prune-status (default
30s TTL, persist — both non-sensitive/small per T-02-01). app-catalog is a
shared key so Discover.vue's identical loader picks up the same entry
without its own conversion in 02-04. Error handling is keep-last-value
(D-07): a rejected refresh sets the existing communityError banner ref,
never a toast, and prior app cards stay on screen.
- Side-effect audit (the precedent 02-04 repeats across remaining tabs):
marketplaceAnimationDone is genuinely once-per-session intro state, stays
in onMounted; the two data loads needed no onMounted/onActivated hook of
their own at all — useCachedResource's internal onActivated (wired in
Task 1) already revalidates them on every kept-alive tab re-entry,
staleness-gated so a quick revisit issues no fetch. No interval,
subscription or window listener exists in this view, so no onDeactivated
teardown was needed either.
- Tests: RefreshIndicator's full render-nothing/render-something matrix
added to keepAliveTabs.test.ts (no router dependency, safe to colocate).
The D-07 rejected-refresh-keeps-content-and-no-toast test mounts
Marketplace.vue itself but lives in a new file,
views/__tests__/MarketplaceRefresh.test.ts, following the in-repo
CloudPeersRefresh.test.ts mount-the-view pattern — vi.mock('vue-router')
is hoisted file-wide, so colocating it in keepAliveTabs.test.ts would
clobber that file's real createRouter/createMemoryHistory used by the
DashboardRouterView tests (Rule 3 auto-fix; deviation from the plan's
literal single-test-file file list, documented here for the SUMMARY).
Marketplace.vue gains a defineExpose({ loadCommunityMarketplace,
loadBitcoinPruneStatus }) for tests, mirroring Cloud.vue's existing
defineExpose({ loadPeers }).
Full suite (87 files / 709 tests), type-check and build all green; the
built Marketplace chunk carries refresh-indicator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires the phase's shared architecture end to end through one main tab
(Marketplace, the worst-measured revisit per 02-FINDINGS.md):
- keepAliveRoutes.ts: exact-match route classifier (shouldKeepAlive,
KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX=6), seeded with only the tracer tab's
path. Deliberately not KeepAlive `include` name-matching (async
components under `<script setup>` have no inferable name).
- DashboardRouterView.vue: extracts Dashboard.vue's nested RouterView into
a host where <KeepAlive> is a permanent element (never torn down by
v-if) with its child conditionally present via shouldKeepAlive(route);
a sibling Transition renders non-cached routes. Both original wrapper
shapes (full-bleed chat/mesh vs. padded/scrollable default) are
preserved via computed helpers on one stable, unkeyed wrapper div; the
:key moves onto <component> itself. Adds per-route scroll retention
since the scroll container is now stable across navigations.
- useCachedResource.ts: registers onActivated(() => refreshIfStale())
alongside the existing onScopeDispose block, closing the gap where a
kept-alive tab would otherwise never revalidate on reactivation
(onScopeDispose doesn't fire on deactivate; window focus doesn't fire
on an in-SPA tab switch). No-ops safely for all 8 existing consumers
outside a KeepAlive boundary.
- useRouteTransitions.ts: exports TAB_ORDER so 02-04 can widen
KEEP_ALIVE_PATHS from the same source of truth.
- Dashboard.vue: renders DashboardRouterView in place of the inline
block; removes the now-superseded detail-route scroll save/restore
(querySelector target no longer exists post-restructure — the new
per-route Map in DashboardRouterView.vue is a strict superset).
Tests: keepAliveTabs.test.ts proves an included path's instance survives
a round trip (1 mount, 2 activations) while a detail path remounts (2
mounts); useCachedResource.test.ts proves no refetch inside the TTL,
exactly one refetch after it lapses, safe use outside KeepAlive, and
keep-last-value + sticky-ready semantics on a rejected refresh.
Full suite (706 tests), type-check, and build all green; built bundle
carries the new KeepAlive wiring (web/dist/neode-ui/assets).
- 02-PERF-BASELINE.json: on-device baseline recorded against archi-dev-box
(real hardware, D-11's verification target). 13/15 surfaces measured
cleanly across 3 runs each; Mesh and Chat are recorded as unmeasured with
their reasons (Mesh: no connected mesh device on this node within the
wait window; Chat: AIUI's own loading overlay outlives the close-button
click budget) rather than silently marked already-fast, per plan rule.
- measure.ts: goHome() now falls back to a hard `page.goto('/dashboard')`
when the Chat surface's own close button is unreachable (blocked behind
AIUI's connecting overlay on real hardware) — without this, every
surface after Chat inherited a permanently hidden sidebar
(`v-show="!chatFullscreen"`) and cascaded to unmeasured. This recovery
path is exempted from the "no page.goto between surfaces" rule because
it exists only to break out of a stuck state, not to measure one.
- surface-perf.spec.ts: currentCommit() now shells out with `process.cwd()`
instead of `__dirname`, which is unavailable under this package's
`"type": "module"` runtime and was silently recording every run header's
`commit` field as 'unknown'.
No file under neode-ui/src/ was modified by this task (git diff --name-only
HEAD -- neode-ui/src is empty).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- surfaces.ts: SURFACES table covering all D-09 surfaces (home/wallet,
apps, marketplace, discover, cloud, mesh, server, web5, fleet, chat) plus
four secondary screens (AppDetails, MarketplaceAppDetails, CloudFolder,
OpenWrtGateway) and the located wallet-send modal. Navigation is via real
RouterLink/button clicks (never page.goto) so revisit measures actual
Vue Router client-side transitions.
- measure.ts: measureSurface() records first-visit vs revisit timing, an
RPC method+timing trace (no bodies), a dataset-stamp remount probe, and
derives maxConcurrentRpc/rpcWallClockMs via sweep-line so serial
waterfalls are distinguishable from parallel fan-outs. Includes a
dismiss-and-retry click guard for stray modals (Companion app intro) that
would otherwise cascade failures across surfaces.
- surface-perf.spec.ts: logs in via the existing app-launch flow, walks
every SURFACES row, writes results + a run header to ARCHY_PERF_OUT.
Verified end-to-end against the local mock-backend + vite dev server
(14/15 surfaces measured cleanly across 3 runs each; the 15th, Mesh, times
out only because the mock backend never reports a connected mesh device —
expected to resolve against a real node in Task 2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the demo build runs inside the Android companion WebView
(window.ArchipelagoNative bridge, detected via the existing
isCompanionApp()), all four IS_DEMO intro branch sites now skip the
typing splash and /onboarding/intro and route directly to /login, as if
the intro was already seen:
- App.vue root-boot replay request (companion never requests the splash)
- App.vue post-splash demo routing
- RootRedirect proceedToApp() and the server-up onMounted demo branch
The skip paths write nothing to localStorage/sessionStorage (RootRedirect
skips even its boot log() there), so the browser/PWA demo intro — which
replays on every fresh root boot — is byte-identical to before, and
non-demo builds short-circuit on IS_DEMO before isCompanionApp() runs.
Adds a small isCompanionApp() bridge-detection unit test (700 tests green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three tab panes (Trusted/Observers/Requests) were hard-capped at
max-h-72 with an mt-auto footer, so a tall sibling Node Visibility card
stretched the shared xl grid row and opened a growing dead gap between
the list end and the Find Nodes / Refresh buttons. The panes are now the
flexible middle of the card's column flex (flex-auto min-h-0, cap lifted
at xl via xl:max-h-none) so the gap is always exactly the footer's pt-4;
below xl the max-h-72 cap and current sizing are unchanged. Footer gets
shrink-0 so buttons can never be compressed by a long list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Card click now dispatches through openItem(): owned -> cached viewer,
paid+playable -> 10% preview, paid non-playable -> pay modal (image is
never fetched pre-purchase), FREE image -> full-screen lightbox streaming
from /api/peer-content (fixes the click no-op where the old ternary fell
through to undefined for non-playable free items)
- Viewer footer caption is state-aware: green 'Owned · unlocked' only for
owned items, neutral 'Free · shared by peer' for free ones; Save streams
free files instead of calling content.owned-get
- Lightning / invoice-QR / on-chain payment successes now share
openPurchased() with the ecash flow: mark owned, autoplay audio in the
bottom bar or open image/video in the viewer (previously a browser
download that silently fails on the mobile companion)
- closeViewer only revokes blob: URLs (free items use plain stream URLs)
- Added a regression test: free image click opens the lightbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Two real Zazawowow tracks from Wavlake (metadata via catalog API, bytes +
artwork committed): WEBFIVEFOURTHREETWOONE is the showcase PAID track
(21 sats), Michael Michael Saylor is free
- Paid/owned downloads now return real file bytes with the correct mime_type
(was a text/plain placeholder) so buying a song autoplays in the bottom bar
- content.owned-list seeded per session with dated purchases matching the
session's federation onions; every purchase path appends to it so Owned
state survives the post-purchase refresh (Paid Files tab now populated)
- peerCatalogFor: deterministic one-peer-per-item assignment + 3 POPULAR
duplicates (was ~25% of items duplicated onto every third peer)
- demoFederationNodes memoised per session so catalogs/owned records/UI agree
- content.preview-peer serves a real audio slice for audio items (paid
preview button plays music, not artwork bytes)
- New GET /api/peer-content/:onion/:content_id Range-capable streaming route
(whitelist lookup, paid items 403) + /api dev proxy in vite.config.ts
- All ten photo-*.jpg picsum placeholders replaced with real Wikimedia
Commons photographs (credited in each description, >=1920px wide)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add an indeedhub entry to staticDevApps in mock-backend.js (state running,
lanPort 8190, existing marketplace title/description/icon). Per-visitor
demo state is structuredClone(staticDevApps), so every fresh session shows
IndeeHub installed in My Apps with no install step. The demo launch URL
bypasses /app/indeedhub/ entirely (iframe loads the :2101 whole-origin
proxy), so no DEMO_APP_PAGES placeholder is added; marketplace metadata
already lists indeedhub following the same pattern as the other static
apps, and uninstall is blocked for static demo apps as usual.
Verified: mock-backend.js boots with DEMO=1 and the /ws/db initial dump of
a fresh session contains indeedhub state=running.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useDemoIntro: replace DEMO_EXTERNAL_URLS (external-tab workaround) with
DEMO_PROXY_PORTS; demoAppUrl('indeedhub') now resolves to
<protocol>//<current-hostname>:2101/ at runtime (no hardcoded host/IP);
isDemoExternal returns false (kept exported so call sites compile
unchanged); isDemoApp still true for indeedhub so the NEW_TAB bypass
keeps it in the in-app session
- useAppIdentity: suppress the identity-picker modal under IS_DEMO — the
embedded IndeeHub is already signed in via the seeded throwaway demo
account; real-node picker behavior untouched (IS_DEMO compile-time false)
Verified: 195 unit tests green (IS_DEMO=false path); VITE_DEMO=1 build
bundle contains the :2101 launch logic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- nginx-demo.conf: new :2101 server block reverse-proxying the live
indee.tx1138.com site with no path prefix (fixes the old sub_filter
path-rewrite breakage), X-Frame-Options/CSP stripped, WS upgrade
passthrough, and a demo sign-in script injected into <head>
- indee-demo-signin.js: PUBLIC-DEMO-ONLY seeder that writes a labelled
throwaway "nsec" account (freshly generated keypair, not a secret) into
the :2101 origin's indeedhub-accounts/indeedhub-active-account
localStorage keys, idempotently, so IndeeHub boots signed in
- Dockerfile.web: copy the seeder into the demo web image, EXPOSE 2101
- docker-compose.demo.yml + demo-deploy/docker-compose.yml: publish 2101
(DEMO_INDEE_PORT override documented in the thin deploy stack)
Verified: nginx -t clean in nginx:alpine; live proxy smoke shows 200 with
no framing headers, injected tag, seed script served, assets proxied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Demo attach flow failed with 'Method not found: mesh.send-content-inline'
and force-opened the transport chooser modal real nodes don't show.
- mesh.transport-advice now mirrors the daemon's size-based tier logic
(typed_messages.rs): chooser only in the fits-both 1-2.3KB band
- implement mesh.send-content-inline / send-content / fetch-content and
POST /api/blob; bytes live in the per-visitor session store
- sent texts + attachments persist in mesh.messages so refresh-after-send
shows them, same as a real node
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Research findings and a concrete split of work: web-side items for this
repo (pre-warm camera, torch toggle, continuous focus, keep-stream-
alive) and a native handover list for the companion dev (pre-warmed
CameraX + ML Kit, QR-only format, 720p keep-latest analysis, torch,
zoom nudge, haptic dismiss) with acceptance criteria and how to
measure. Quick win landed now: live scan runs at 10 scans/sec when the
platform has a native BarcodeDetector, keeping 4/s only for the
JS-worker fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Framework-pt report: a paid invoice stalled the UI with no success
shown, and lightning/total balances disappeared until it settled.
Three compounding causes, three fixes:
- Backend payinvoice's synchronous wait drops 120s → 8s. Fast payments
(the majority) still settle in one round trip; slow multi-hop routes
return pending + payment_hash quickly and the caller's 3s poll takes
over — instead of the modal freezing for up to two minutes.
- payLightningInvoice gains an onPending hook: SendBitcoinModal and the
scan modal now flip to a visible "Settling…" success pane the moment
the payment goes pending (safe to close), and the ongoing poll
upgrades it to Paid — or replaces it with LND's real failure.
- One slow lnd.getinfo poll (5s budget) flipped the Home wallet card to
"disconnected", hiding balances the user already knew. Three
consecutive failures are now required (~30s) before the card gives up;
last-known balances keep rendering throughout.
rpc-client tests 75/75.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Another update operation is already running" surfaced as a scary
failure while the update was in fact applying fine (OptiPlex, v1.7.118
rollout). The apply path now joins the in-flight install — same
overlay, same wait-for-new-version polling — and a concurrent download
attempt shows a calm in-progress note (EN+ES strings added). The
backend's tarball extractions run under ionice -c3 nice -n10 so a
200MB update can't starve podman/status calls into multi-minute
timeouts on small disks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ~8s into a session a second "freshly plugged" RNode appears on
/dev/ttyACM0, so the global mesh setup modal (and its flash step)
demos itself shortly after opening the Mesh page. Fixed plugged_at
means "Not now" sticks for the whole browser session.
- Transport pills (LoRa/FIPS/Tor) now offered for every demo peer
except mountain-node, which stays radio-only for contrast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- mesh.transport-advice: peer 1 federated (LoRa/FIPS/Tor pills in the
image modal), others radio-only.
- Scripted Flash LoRa job: flash-list-firmware + flash-device +
flash-status advancing download → erase → write → done over ~35s
with live log tail and percent, plus cancel.
- Demo messages carry per-message transport (meshcore/reticulum/fips/
tor variety for the pills + animated route modal) and
sender_pubkey/sender_seq so reactions/replies work.
- Chat-action acks: send-reaction/reply/read-receipt, edit/delete/
forward, send-channel, mesh.refresh, reboot-radio.
- Services classification demo: self-deployed "podsteadr" stack — main
app launchable, its MediaMTX backend (ui:null) files under Services
with no Launch button, mirroring ui_detection's verdicts.
All verified against the running mock: 11/11 runtime checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unread badges came back on every visit (framework showed a phantom "2"
with nothing new): unreadCounts was memory-only, so each page load
replayed the entire message history as "new". Seen-state now persists
as a per-contact highest-seen-message-id watermark in localStorage
(ids are backend-monotonic across restarts); first run after this
ships seeds the watermark from history so nobody gets a wall of stale
badges. Opening a chat advances and persists the watermark for all
twins of the merged conversation.
The hop-route modal the user asked for is now reachable from a visible
per-message "⋯" button (the transport pill remains clickable too), has
a fallback title/branch for messages that predate transport tracking,
and animates: endpoints and link reveal in sequence, a pulse travels
the link, and relay dots blink in order — all disabled under
prefers-reduced-motion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrites the prepared 1.7.117 section to include the mesh flash flow,
first-class Reticulum fixes, radio-first routing, mesh chat polish, the
transactions-modal phone fixes, services-vs-apps classification, the
lightning slow-payment fix, cached-resource page loads, load-shedding,
FIPS uptime hardening, and companion 0.5.25. What's New modal block
regenerated from the new bullets (sync-whats-new --check passes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A published port no longer implies a web UI. The package scanner used to
synthesize interfaces.main.ui="true" for any container with a port or
onion address, so headless backends — including self-deployed compose
stacks like podsteadr — showed up as launchable apps. New ui_detection
module decides instead: a manifest interfaces declaration (catalog
overlay first, disk second) is definitive; undeclared apps get a short
HTTP probe of the launch port (HTML page, redirect, or browser auth
wall = UI; JSON APIs, raw TCP, dead ports = service), with cached
verdicts and probes gated on running containers. Frontend canLaunch
now refuses curated services outright and only treats a bare runtime
address as launchable for curated known apps.
Works identically for manifest apps and containers deployed by hand
outside the orchestrator. ui_detection tests 6/6, frontend suite
696/696.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chips become sticky inside the modal's scroll region with a dimmed
blurred band, so the rail filter stays reachable while rows scroll
underneath. Verified in headless chromium: pinned at scroll-region top
after deep scroll, touch swipes starting on the chips still scroll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the external flasher.meshcore.co.uk link with a button that
opens the global device-setup modal directly at its flash step, via a
new manual entry point in the mesh store (flashFlowPath). Manual opens
target the connected radio (else the first detected stick), skip the
read-only probe — the port is held by the live session and a second tty
opener corrupts it; the backend flash job stops the listener itself —
and close the modal instead of stepping back to the detection screen.
Button is disabled with a hint when no radio is present.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tx list kept a vestigial overflow-y-auto from before the modal
contract refactor made BaseModal's slot wrapper the scroller. With a
modal open, modal-scroll-locked applies overscroll-behavior:contain to
every .overflow-y-auto inside the overlay, so touch scrolls latched
onto the non-scrollable inner list and could not chain up to the real
scroller — the modal was unscrollable on any touch device. Wheel input
latches onto the scrollable ancestor directly, which is why desktop
never showed it. Verified with headless-chromium touch synthesis at
360x640/320x568: list scrolls to bottom, background stays contained.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Image quality modal: 'Send via' pills (LoRa / FIPS / Tor) when the
peer is federation-reachable — mesh.transport-advice now returns
has_fips + last_transport alongside has_tor. Picking FIPS/Tor routes
the image over the content-ref path instead of the radio.
- Attachment modals (transport chooser, image quality, new hop modal)
Teleport to body so the backdrop dims the FULL viewport — rendered
in-place they sat inside a transformed glass panel that trapped
position:fixed to the right chat panel.
- Click a message's transport pill → route modal: radio hops + live
SNR/RSSI quality for LoRa transports, overlay/circuit shape for
FIPS/Tor, delivery + E2E state.
- Reactions move behind a compact 'React ▾' dropdown with a larger
12-emoji palette.
- Unread badges now clear like a normal chat app: opening a contact
clears ALL twins of the merged conversation (badge sums every
contact_id — clearing just the clicked one left it stuck), and only
once the chat has scrolled to the latest messages; scrolled up into
history, new arrivals accumulate until you scroll back down.
- Refresh button shows only the spinner while refreshing (text+spinner
overflowed the fixed button width).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two halves of the same twin-resolution gap, found live while testing
images between archi-dev-box and archy-x250-dev:
- peer_dest_prefix resolved the given contact row's own pubkey. For the
UI's merged conversation (the federation-synthetic id) that's the
Archipelago ed25519 identity key, NOT a radio routing key — so every
Reticulum resource send (images/files over LoRa) failed with 'Unknown
Reticulum prefix' while the UI showed the message as sent. It now
resolves through the radio twin (same arch identity, radio-range id).
- send_typed_wire sent EVERY federation-synthetic contact over the
federation path (FIPS→Tor), even with the same node one LoRa hop away.
Policy per operator: LoRa first when the payload fits and the radio
twin is reachable, then FIPS, then Tor. Verified live: text to the
merged contact now logs 'Radio-first routing' and lands with
transport=reticulum on the peer.
Also restyles the mesh-chat attachment download controls: the pre-fetch
button was a bare .btn that squished to text width in the narrow mobile
bubble; now a full-width glass pill with a download icon and fetch
spinner, and the on-image overlay swaps the emoji glyph for a crisp SVG
in a properly-sized glass circle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Refresh button: real handler — calls the new mesh.refresh (radio
re-query) plus contacts/federation/outbox re-reads, disabled with a
spinner while running (was an unawaited cache repaint with no feedback
that skipped half the list's data sources).
- Broadcast button: success ('Sent ✓') and failure states with the error
in the tooltip; failures no longer vanish as unhandled rejections.
- The store's 5s status poll no longer wipes the error banner each tick.
- Contacts/aliases, federation nodes and the outbox badge refresh every
~30s (were mount-only and went permanently stale).
- Peer-list empty state keys on the merged list, so federation rows and
the channel rows still render with no radio attached.
- Device setup modal: 'Set Recommended' naming, probe progress bar with
stage labels instead of an anonymous spinner.
- Device panel: name save clears properly (empty = fall back to server
name) and the confirmation reflects the new live apply.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two FIPS containers on the Server page were the last network cards
still fetching-on-mount into local refs — every visit was a blank card
until fips.status / fips.list-seed-anchors answered. Both now ride the
cached-resource layer: FipsNetworkCard shares the server.fips-summary
key with the Local Network card's FIPS row (one fetch, never disagree),
seed anchors cache under server.fips-seed-anchors, and mutations
write the RPC's authoritative result straight into the cache. The 15s
status poll skips hidden tabs — revalidate-on-focus covers the return.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bridge WebSocket patches into the resource layer: a /peer-health/<onion>
patch invalidates that peer's cloud.peer-browse entry and the federation
node list; /package-data patches invalidate the tor-services list.
invalidate() debounces 800ms and refetches only keys with mounted
subscribers, so patch storms cost one revalidation per key; the 30s
staleness reconciliation remains the backstop for unmapped data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Credentials: identity.list + identity.list-credentials become
useCachedResource entries; explicit reloads still toast on failure.
- OpenWrtGateway: openwrt.get-status caches in the shared store (revisits
paint the last router state instantly); the connect flow's
params/No-router-configured semantics are preserved on top of the entry.
- ContainerApps assessed and left as-is: its Pinia store already persists
across navigation, keeps last data on error, and gates the spinner on
empty — same class as Apps/Marketplace/Fleet.
This closes the B4 rollout list from docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The keeps-data-visible-while-refreshing tests for PeerFiles, Server, and
LightningChannels mounted without Pinia (the converted components now
pull the resources store in setup) — add createPinia to the mounts.
LightningChannelsPanel: refresh the main channel list before the closed
history so the primary entry gets the first response, and null-guard
both fetchers' response shapes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
network summary (4-RPC allSettled aggregate), fips row, vpn peers,
interfaces, and tor services become useCachedResource entries — revisits
paint instantly, background refreshes keep content on screen. Mutations
write through the cache: DNS apply + the 15s vpn poll patch the network
aggregate via optimistic() instead of refetching all four RPCs; peer
removal filters the cached list. loading/refreshing flags derive from
entry loadState (drops the hand-rolled hasLoaded bookkeeping).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
federation nodes + dwn.status become useCachedResource entries: revisits
paint the node list instantly, `loading` fires on true first-load only
(the old showLoader semantics), the 5s poll refreshes silently like the
old surfaceErrors:false path, and explicit reloads after mutations still
surface failures in the error banner. Replaces the hand-rolled
loadNodesWithOptions SWR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lnd.listchannels (+summary) and lnd.closedchannels become separate
useCachedResource entries: reopening the panel paints the last channel
lists instantly and revalidates behind them; a closed-history failure
keeps its last list without touching the main view (same semantics as
the old nested try). Open/close mutations still force a refresh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
monitoring.current/history/alerts/alert-rules become useCachedResource
entries: revisiting the page paints the last snapshot, chart, and alert
list instantly and the 5s poll revalidates behind them (refreshes dedup
in the store; errors keep last-known values instead of blanking).
Alert-rule toggles and acknowledgements refresh their entries after the
mutation as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- lnd.getinfo and wallet.networking-profits become useCachedResource
entries (web5.lnd-info / web5.networking-profits): revisits paint
instantly from cache, errors keep last-known values, refreshes dedup.
- walletConnected is now derived from the lnd-info entry (with a manual
disconnect override preserving the connect/disconnect toggle).
- Drop the eager wallet.ecash-balance + lnd.gettransactions loaders and
their 30s polling — they fed only the hidden wallet card; the lnd-info
poll remains for the connected pill until B5 moves it to WS-push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- PeerFiles.vue reads the SAME `cloud.peer-browse:<onion>` entry Cloud.vue's
per-peer fan-in fills, so Cloud → peer files paints instantly from cache
and revalidates behind it; catalog/error/loading/transport are now
computed views over the store entry.
- preview-peer fan-out is capped at 3 concurrent with a queue (was one 30s
RPC per media item, all at once, unbounded) and aborts on unmount.
- browse + preview RPCs drop to maxRetries:1 — retry×3 turned one slow
peer into a 90s spinner.
- fix useCachedResource's interface types: `ReturnType<typeof computed<T>>`
resolves to the writable overload (WritableComputedRef), which broke
vue-tsc against the plain computed() returns; use ComputedRef<T>.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>