Alpha-stage, user-approved: a prompt only reaches users who click it, so
security fixes sat unapplied in long-lived sessions (installed PWA, kiosk
displays). Extends the existing kiosk-only auto-apply to all non-demo
clients.
Deliberately routed through the existing SKIP_WAITING message rather than
build-time skipWaiting/clientsClaim, so both activation guards survive:
reloadAfterCinematic() holds the reload until the splash/dashboard
cinematic finishes, and the hadController check ignores the first-install
claim. A build-time skipWaiting would bypass both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now that persist is required (no default) on useCachedResource()/refresh(),
every call site that previously relied on the implicit persist:true default
needs an explicit decision. Full audit, decision rule: money/identity/
peer-identity payloads -> false; static/aggregate/non-identifying data ->
true; ambiguous cases fail safe to false and are called out below.
persist:false (financial / identity / peer-identity payload):
- LightningChannelsPanel.vue: lnd.channels, lnd.closed-channels (open/closed
Lightning channel balances — wallet data, same class as CR-01's lnd-info)
- Cloud.vue: cloud.paid-items (carries paid_sats + purchase history),
cloud.peer-nodes (PeerNode carries did/pubkey/onion)
- Cloud.vue/PeerFiles.vue: cloud.my-files — not a clean money/identity/
peer-identity case, but a private per-user file listing; chosen false as
the fail-safe default per the audit rule, flagged here for review
- Credentials.vue: credentials.identities, credentials.list
- Federation.vue: federation.nodes (FederatedNode carries did — matches
Mesh.vue's already-persist:false federation.nodes decision)
- FipsSeedAnchorsCard.vue: server.fips-seed-anchors (SeedAnchor carries npub)
- Server.vue + FipsNetworkCard.vue: server.fips-summary corrected from
persist:true to persist:false — this shared cache key's real fips.status
response carries npub (this node's own FIPS identity key), which
Server.vue's narrower local type didn't surface but FipsNetworkCard.vue's
fuller FipsStatus type does; both call sites must agree since a mismatch
trips the dev-only entry() persist-consistency warning. Found during this
audit, not part of the originally-scoped call-site list — corrected as a
same-class T-02-01 violation. serverTabCache.test.ts updated to match.
persist:true (aggregate/status/public data, no identity or money):
- AppDetails.vue: app-details:bitcoin-sync (block height/sync progress)
- Cloud.vue: cloud.section-counts (bare per-section item counts);
cloud.peer-browse (browsePeer()/loadCatalog()'s direct resources.refresh()
calls now pass { persist: true } explicitly, matching the pre-existing
decision already documented at peerBrowseEntry())
- Federation.vue: federation.dwn-status (sync status/counters only)
- MarketplaceAppDetails.vue: app-details:versions (public catalog metadata)
- Monitoring.vue: monitoring.current/history/alerts/alert-rules (system
metrics and alert metadata only)
- OpenWrtGateway.vue: server.openwrt-status (network/router status, matches
sibling server.* resources)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CR-01 fixed web5.lnd-info/web5.networking-profits to persist:false, stopping
FUTURE writes to sessionStorage, but a tab already open before the update
ships reloads in-place onto the new bundle and keeps whatever the OLD
bundle already wrote under the old decision — indefinitely, since nothing
but clearAll() (logout) ever purges a resource: snapshot. Long-lived tabs
(installed PWA, kiosk display) are normal here, so this left updating users
exposed to exactly the T-02-01 exposure CR-01 was meant to close.
- Add a schema-version marker (resource:__schema) checked once at store
setup: absent or stale marker purges every resource:-prefixed
sessionStorage key, then writes the current version. One-time per tab
session (a matching marker no-ops), not per navigation/reload, so this
doesn't defeat the instant-paint-from-snapshot benefit the cache exists
for. CURRENT_SCHEMA_VERSION must be bumped whenever a key's persist
decision changes, documented inline as the contract for future changes.
- Extract clearAll()'s purge loop into purgeAllSnapshots(), reused by both
clearAll() (logout, T-02-02) and the new migration, so there's one place
that enumerates/removes resource: keys.
- Close the residual refresh()/useCachedResource() default: opts.persist
?? true was the exact footgun that caused CR-01 (a call site silently
opting into persistence by omission). persist is now a required
parameter on refresh() and useCachedResource()'s options, matching the
entry()/optimistic() hardening WR-04 already applied.
- Tests: legacy snapshot (no/stale marker) is purged on init; a snapshot
under the current marker survives a later init (proves one-time, not
every-boot); persist:false never writes a snapshot; marker is written
after purge; purge is strictly bounded to the resource: prefix (seeded
non-resource: sessionStorage keys and a localStorage auth flag survive
byte-for-byte); migration cannot race an in-flight fetch (runs
synchronously at store setup, before entries/inflight can hold anything).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps to botfights:1.2.3, which carries the JoinBoutPage.vue fix from the
botfight repo (commit 603e09b): the poll/webhook mode picker on the bot
setup step now visibly reacts when clicked — a colored banner in the guide
viewer and a prepended line in the copied prompt point at "Option A:
Polling Bot" or "Option B: Webhook Bot" within the single unified doc
(BOT-02), instead of silently refetching the same file with no visible
change. No manifest/env changes beyond the version/image bump — 1.2.2's
ARCHY_EMBEDDED/ARENA_UPSTREAM_URL/generated JWT_SECRET are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 2 (no-op branch, per plan): Task 1's evidence positively proved
Server.vue and Web5.vue's instances already survive tab round-trips —
the "remounts" reading was a probe artifact (02-FINDINGS.md), not a real
defect. No change to DashboardRouterView.vue, dashboardViewWrappers.ts,
keepAliveRoutes.ts or Server.vue's KeepAlive/lifecycle wiring.
Lands 4 regression tests in keepAliveLifecycle.test.ts using Vue's own
component-instance identity (vm.$.uid) instead of a CSS selector, so the
pin can't inherit the same generic-.view-container ambiguity Task 1 found:
round-trip identity for Server (Test 1) and Web5 + a second tab (Test 2),
include-list correctness (Test 3), and the LRU cap staying intact (Test 4).
All four pass immediately against the unmodified code — that pass is
itself the pin, per the plan's explicitly anticipated no-change path.
Full suite green (95 files / 778 tests), type-check clean, build succeeds.
keepAliveTabs.test.ts confirmed byte-for-byte unmodified and still green.
No deploy: nothing in neode-ui/src changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1: built keepalive-remount-probe.spec.ts, a committed, re-runnable
Playwright spec covering every KEEP_ALIVE_PATHS tab with three added
instruments (instance-uid, session-wide console/pageerror capture, DOM
population/pathname logging per hop) beyond 02-08's ad-hoc probe.
Eliminated suspects 2 (runtime error), 3 (LRU eviction), 4 (route.path
mismatch) and 5 (include-name matching) by direct measurement. Confirmed
suspect 1 with positive proof: an authoritative document.elementFromPoint()
hit-test signal contradicted the naive selector-match method's "remounted"
verdict for both Server and Web5 across independent runs, and a companion
diagnostic found the original stamped root still connected+visible under
a different (unpicked) match. Root cause: Server, Web5 and Fleet share the
fully generic .view-container [data-controller-container] selector every
KeepAlive-cached main tab's root carries via fallthrough, which cannot
disambiguate "the foreground tab" from "another cached tab still connected
to the document" once more than one tab has been visited — the normal,
intended KeepAlive state. Settings (the away tab every round trip uses)
independently renders matching content too (AccountInfoSection/
KioskDisplaySection), compounding the ambiguity.
Server.vue and Web5.vue's instances survive tab round-trips exactly like
every other registered tab — no defect in DashboardRouterView.vue,
dashboardViewWrappers.ts, keepAliveRoutes.ts or Server.vue's KeepAlive
wiring. 02-FINDINGS.md records the full method, eliminated suspects and
verdict per D-10 (this commit lands before any src change, per gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1.2.x's auth-hardening work added Hono secureHeaders() with a default
X-Frame-Options: SAMEORIGIN, which unconditionally blocked the Archipelago
node dashboard's iframe (different origin by port) — a real regression
versus 1.1.0, which never sent this header. Fixed upstream in the botfight
repo (commit 8eb27ed): X-Frame-Options is now conditional on ARCHY_EMBEDDED,
disabled only for the first-party node-embedded instance.
apps/botfights/manifest.yml: image/version -> 1.2.2, adds
ARCHY_EMBEDDED=1 to environment, drops the interim
metadata.launch.open_in_new_tab workaround (no longer needed — the app can
now be framed). app-catalog/catalog.json, scripts/image-versions.sh,
neode-ui/public/catalog.json bumped in lockstep via
scripts/generate-app-catalog.py. core/archipelago/src/fips/app_ports.rs
regenerated (formatting only, same port set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isFullBleedPath(path) only returns true for /dashboard/chat and
/dashboard/mesh, both always in KEEP_ALIVE_PATHS today, so this branch of
wrapperFor()'s key derivation is currently unreachable. Add a comment
(per the review's own "non-blocking; a comment is sufficient" fix) so a
future reader doesn't mistake the defensive branch for dead code to delete.
Co-Authored-By: Claude <noreply@anthropic.com>
server.network-summary's fetcher batches four RPCs but only two forwarded
the AbortSignal useCachedResource provides for abort-on-unmount;
rpcClient.vpnStatus()/dnsStatus() had no signal parameter at all, so
aborter.abort() couldn't cancel them, partially defeating the documented
abort-on-unmount contract for this resource.
Add an optional signal parameter to both convenience methods (mirroring
the pattern used throughout rpc-client.ts) and forward it from Server.vue.
Co-Authored-By: Claude <noreply@anthropic.com>
entry(key, persist = true) and optimistic(key, update) silently defaulted
to persist:true after the first call for a key, and optimistic() didn't
accept a persist argument at all. Every current call site happened to be
safe, but the invariant was unenforced: a future caller invoking
store.optimistic() before any useCachedResource({persist:false}) has run
for that key in the same tick would silently start writing to
sessionStorage with no indication anything is wrong (T-02-01).
persist is now a required argument on both functions (no default), and the
per-key decision is recorded and asserted (dev-only warning) against any
later call that disagrees. useCachedResource's optimistic() wrapper now
threads its own already-resolved persist value through automatically, so
no existing composable caller changes behavior. The two call sites that
use the resources store directly (Cloud.vue/PeerFiles.vue's per-peer
browse cache) now pass persist:true explicitly, matching their existing
behavior exactly.
Co-Authored-By: Claude <noreply@anthropic.com>
load(params) routed every call -- including the Connect form's own
credentials -- through routerResource.refresh(), which resources.ts dedupes
per key. A second load({host, ssh_user, ssh_password}) call arriving while
an unrelated refresh was already in flight (e.g. useCachedResource's own
TTL-gated auto-revalidation) would just await that already-in-flight
promise; the caller's own params were silently never sent, with no error
surfaced.
load(params) now bypasses routerResource.refresh() entirely when explicit
params are supplied, calling rpcClient directly and writing the resolved
result into routerResource.entry so cache/TTL/status-panel rendering stays
consistent with a normal refresh() success. The plain reconnect path
(no params) is unchanged. The now-redundant pendingParams indirection is
removed since the fetcher only ever needs `{}` params going forward.
Co-Authored-By: Claude <noreply@anthropic.com>
armMapVisibility() unconditionally scheduled a setTimeout(initMap, 300) on
every reactivation, not just the first mount. initMap()'s own guard made
this harmless (a no-op once the map exists), but it scheduled a throwaway
timer on every tab-switch back into Mesh. Guard the scheduling itself so
intent ("fallback init for the very first mount") matches behavior.
Co-Authored-By: Claude <noreply@anthropic.com>
onDeactivated only tore down the resize listener/ResizeObserver, not the
navigator.geolocation.watchPosition watch started by "Share Location" —
leaving GPS polling running in the background (battery drain, active
location indicator) for as long as the KeepAlive'd component survives,
instead of only while the Mesh tab is visible like every other resource
this phase added only-while-visible handling for in this file.
onDeactivated now stops an active watch (tracked via a flag rather than
losing the user's toggle state), and onActivated transparently resumes it
on return to the tab.
Co-Authored-By: Claude <noreply@anthropic.com>
Marketplace.vue and Discover.vue both register a useCachedResource against
the shared 'app-catalog' key with different fetchers; resources.ts's
in-flight dedup means whichever view's fetcher wins a given race governs
the shared entry, silently dropping Discover's catalogFeatured side effect
when Marketplace's simpler fetcher wins.
Give the featured-banner payload its own cache key ('app-catalog:featured')
subscribed only by Discover.vue, so it always gets its own data regardless
of which view's fetcher wins the shared 'app-catalog' race. fetchAppCatalog()
already memoizes internally (1h TTL + localStorage fallback), so this is
normally a cache hit rather than an extra network request. The shared
'app-catalog' key and its dedup behavior are unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Web5.vue's lndInfoRes and profitsRes defaulted to persist:true (via
useCachedResource's default), writing live LND wallet balances and
channel balances to sessionStorage in plaintext -- a T-02-01 violation.
Add explicit persist:false to both, matching the "never defaulted"
rule this phase established everywhere else. Also updates Home.vue's
comment, which previously documented this as a known unfixed gap.
Co-Authored-By: Claude <noreply@anthropic.com>
Instrumented the exact trigger the fresh-mount guard (previous commit)
didn't fully eliminate: on a fresh session, loadPeerFiles() fired one
content.browse-peer RPC per connected peer with zero concurrency cap
and a 30s per-call timeout. Confirmed on archi-dev-box: 13 of 14
concurrent browse-peer calls never settled at all (dead/unreachable
peers with no server-side timeout on that path) — that many
simultaneously open, indefinitely-pending same-origin requests starved
Chromium's connection pool, silently breaking every other same-origin
fetch for the rest of the session, including the lazy route chunk any
later folder/tab navigation needs. This — not the router or the click
handler — was the actual cause of "no folders open on click" after a
first Cloud visit.
Fix, mirroring PeerFiles.vue's existing PREVIEW_CONCURRENCY pattern for
the identical class of problem (content.preview-peer fan-out):
- Cap the browse-peer fan-out at 3 concurrent requests
(BROWSE_PEER_CONCURRENCY, a queue+worker pool in loadPeerFiles()).
- Shorten each call's timeout from 30s to 10s (BROWSE_PEER_TIMEOUT_MS) —
bounds how long any one dead peer can hold a connection.
- Wire an AbortController (aborted onUnmounted) through rpc-client's
signal option for clean teardown.
- A timed-out/failed peer already resolved silently through
resources.ts's own error-state path (no throw, no toast) — confirmed
unchanged; the muted "N peers unreachable" line is the only surface.
No visual/behavioral change to the working case (D-01 rule) — peers
that answer still render exactly as before, just no longer share the
page with a dozen never-ending requests.
Full suite green (95 files / 774 tests), type-check and build clean,
keepAliveTabs.test.ts structural DOM assertions untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-reported checkpoint regression: on a genuinely first visit to Cloud
this session, no folder opened on click (subsequent visits fine).
Root-caused on archi-dev-box, not guessed:
- The click DID register (confirmed via a direct DOM listener) and DID
call router.push (confirmed by patching the live router instance) —
but the push's promise never settled, because Vue Router awaits the
target route's async component, and that dynamic import() itself
never resolved.
- Confirmed via a manual import() from the page console: importing ANY
lazy route chunk (Fleet, CloudFolder, AppDetails — unrelated views)
hangs identically after visiting Cloud once, but works instantly
before ever visiting Cloud. Not chunk-specific, not router-specific.
- Traced to exactly one permanently-pending network request: a File
Browser `GET /app/filebrowser/api/resources/Photos` call from Cloud's
own onMounted burst, confirmed hung via request-lifecycle tracking
(never finishes or fails, still pending after 10s). The identical
request, issued manually with a fresh token outside of Cloud.vue,
returns in 29ms — ruling out the backend/File Browser itself.
- Mechanism: Cloud.vue's syncOnEntry() (loadCounts/loadPeers/
loadPeerFiles) fires from BOTH onMounted and onActivated with no
fresh-mount guard (02-04 exempted it, reasoning each resource is
individually staleness/inflight-deduped — true per-resource, but the
two back-to-back passes still double the concurrent request volume
at the single riskiest instant in a session: first KeepAlive
activation, stacked on whatever other cached view's own onMounted
burst is firing at the same moment). On real hardware that volume
was enough to leave one File Browser request stuck, which then
starves Chromium's per-origin connection pool — breaking every
subsequent same-origin fetch, including the lazy chunk any later
navigation needs. Not a router or click-handler bug; the click and
push both worked correctly the whole time.
Fix: give Cloud.vue the same fresh-mount guard already used in
Home.vue/Web5.vue/Mesh.vue/Server.vue (skip onActivated's redundant
first-activation re-fire since onMounted just ran it). Removes the
duplicate-burst mechanism without changing steady-state reactivation —
onActivated still re-syncs normally on every later KeepAlive round-trip.
No visual/behavioral change (D-01 rule).
Full test suite green (95 files / 774 tests), type-check and build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deployed this phase's frontend + AIUI to archi-dev-box (the dev pair;
x250-dev is currently offline via Tailscale, recorded rather than
silently skipped) via scripts/deploy-to-target.sh --frontend-only.
Verified the served bundle (not just local dist) contains this phase's
changes.
Then took a real on-device measurement instead of leaving the FA-D
KEEP_ALIVE_MAX estimate unexamined: a headless Chromium session on
archi-dev-box cycled all 11 main tabs through 4 full round-trips (44
navigations), reading the JS heap via CDP before/after each cycle.
Memory fluctuated 10-21MB with no monotonic growth across cycles that
each exceed the cap 10 distinct registered paths against KEEP_ALIVE_MAX=6.
Left the constant at 6, now backed by a recorded measurement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Belt-and-suspenders fix on top of AIUI's own root-cause fix (the
archyBridge origin bug, fixed in the AIUI repo): the archy-side loading
overlay now gets pointer-events:none (it has no interactive content of
its own, so it should never have blocked clicks reaching the iframe
underneath) and a bounded 8s timeout that unconditionally hides it if
no 'ready' message ever arrives — regardless of AIUI/backend state.
The timeout only dismisses the overlay; it does not fabricate a
successful connection, so the connected indicator still reflects
reality.
Two new tests in chatAiuiEmbed.test.ts cover the timeout firing at
exactly 8s and not firing prematurely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aiuiUrl now appends chatExpanded=true and mobileChat=true alongside
the pre-existing embedded=true and hideClose=true. Both are static
strings with no reactive dependency, so the computed's value never
changes after first evaluation — preserving the URL-stability
contract 02-04 relies on to keep the AIUI iframe from reloading on a
tab switch.
AIUI reads these two flags (commit 900c0b9 in the AIUI checkout,
recorded in 02-AIUI-D14.md) to start the chat expanded and, on
mobile, on the chat view rather than the context view. The deployed
AIUI build on any node does not yet carry that commit (anonymous push
to the AIUI remote was rejected — see 02-AIUI-D14.md's Deployment
Impact), so neode-ui's two new query params are inert no-ops against
today's deployed AIUI until that commit is merged and shipped; both
flags are additive and harmless in the meantime.
New test file chatAiuiEmbed.test.ts covers: both D-14 flags plus the
pre-existing embedded/hideClose params present in the URL; URL
string-equality across a simulated viewport resize and across a
KeepAlive deactivate/reactivate cycle; onAiuiMessage still rejecting
a foreign-origin message; and aiuiConnected surviving a
deactivate/reactivate cycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- System stats (homeStatus.refresh), update status and cloud storage usage
are now every-entry, TTL-gated useCachedResource entries (10s/300s/30s),
hosted in Home.vue rather than inside the homeStatus Pinia store — a
store's own defineStore(id, setup) runs in a bare effectScope where
onActivated() silently no-ops (same finding as 02-05's Mesh.vue)
- Wallet is the deliberate exception (T-02-13): a new home.wallet-status
resource wraps the existing loadWeb5Status() composite fetch and
revalidates UNCONDITIONALLY on every activation rather than TTL-gated,
keeps the prior figure rendered throughout, and persist:false (never
written to sessionStorage). hydrateWalletSnapshot()'s separate localStorage
path is untouched.
- Read Web5.vue's two existing resources (web5.networking-profits,
web5.lnd-info) and did NOT share either key: profits is an unrelated
dataset, and lnd-info's own default persist:true (Web5.vue out of this
plan's file scope) would leak balance data via its own independent
refresh cycle regardless of what Home declares, and Home's wallet fetch
is a strictly broader 7-call composite (not the same single-call dataset)
- dedup:true added to all 12 underlying rpcClient calls (Home.vue's wallet
composite + checkUpdateStatus, homeStatus.ts's 5 status calls)
- RefreshIndicator wired next to the Home header, bound to the wallet
resource's loadState
- The websocket wallet-push path and hydrateWalletSnapshot's pre-network
paint are both left exactly as 02-04 placed them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Server.vue already had five of the seven load groups (network summary, FIPS
summary, VPN peers, interfaces, Tor services) on useCachedResource from a
pre-phase commit, but none declared an explicit ttlMs/persist (relying on
the composable's 30s/persist:true defaults) and dedup:true was missing from
several underlying RPC calls. loadDiskStatus was the one remaining plain
uncached fetch, forced on every activation.
- Explicit TTL per group (10s fast tier: network-summary/interfaces/
disk-status; 30s near-default: vpn-peers/tor-services; 60s near-static:
fips-summary)
- Explicit persist:false for server.vpn-peers (npub/peer identity) and
server.tor-services (onion addresses) per T-02-01; other groups persist
- New server.disk-status cached resource replaces the plain fetch; it now
self-heals via the composable's own onActivated instead of an explicit
every-entry call
- dedup:true added to all underlying rpcClient calls, including the
parameterless vpnStatus()/dnsStatus()/diskStatus() convenience methods
- RefreshIndicator wired into a new minimal header row, driven by whether
any of the six cache entries is refreshing
- RESEARCH assumption A3 settled: read all seven loader bodies; none
consumes another's result — the concurrent fan-out is correct as-is
- Fixed a keepAliveLifecycle.test.ts assertion invalidated by the new 10s
network-summary TTL (reactivation now also revalidates that resource,
adding one more vpnStatus() call the test didn't previously account for)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MeshMap.vue's onMounted setup (window resize listener + ResizeObserver)
is refactored into an idempotent armMapVisibility()/disarmMapVisibility()
pair, dual-registered on both onMounted and onActivated (onActivated is a
documented no-op outside a KeepAlive boundary) and torn down on
onDeactivated, matching the arm/disarm idiom Mesh.vue itself already uses.
The Leaflet instance is never destroyed or recreated by this — initMap()'s
own `if (!mapContainer.value || map) return` guard already makes
construction idempotent, so exactly one map is built per session. On
reactivation the map's size is invalidated via nextTick so a map laid out
while off screen re-tiles at its real size instead of showing an unsized
or partially tiled canvas.
FLAGGED: RESEARCH.md's premise that Mesh.vue owns a live D3 force
simulation does not hold for this codebase — a grep for
d3/forceSimulation/simulation across neode-ui/src found nothing in
Mesh.vue's or MeshMap.vue's tree; the only D3 force simulation belongs to
NetworkMap.vue (Federation.vue's graph, out of this plan's scope). The
plan's D3-specific truths are therefore vacuously satisfied — see
02-05-SUMMARY.md for detail. Only the real Leaflet-map lifecycle work
landed here.
meshMapLifecycle.test.ts is a new, separate file (not appended to
meshTabCache.test.ts) because its vi.mock('@/stores/mesh')/vi.mock('leaflet')
hoist file-wide and would otherwise clobber meshTabCache.test.ts's need for
the real mesh/transport stores — mirrors the MarketplaceRefresh.test.ts
precedent from 02-02 for the same class of vi.mock-hoisting conflict.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Mesh.vue: six useCachedResource entries wrap mesh.refreshAll(),
transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(),
refreshSelfDid() and refreshContacts(), each with an explicit TTL
(10s reachability/transport, 30s federation/contacts, 300s self
DID/onion) and persist decision (T-02-01: every group carrying peer
or self identity data is persist:false; only aggregate transport
status may persist)
- armMeshLive's Promise.all fan-out becomes a single Promise.allSettled
over refreshMeshGroupIfStale() per group, so a revisit inside TTL
issues zero RPC, a stale revisit revalidates concurrently (not a
serial chain, T-02-16), and one rejected group never blocks the rest
- RefreshIndicator wired into the Mesh header, driven by whether any of
the six groups is refreshing — visible while peer reachability
revalidates on re-entry so a resumed tab never shows a frozen
reachability state as current (T-02-13)
- dedup: true added to every underlying rpc-client.ts/mesh.ts/
transport.ts fetcher call backing the six groups
- useCachedResource() calls live in Mesh.vue rather than inside
stores/mesh.ts or stores/transport.ts: Pinia's defineStore(id, setup)
runs in a bare effectScope, not a component instance, so the
composable's internal onActivated() would silently no-op there; the
fetchers still wrap the stores' own actions unchanged, so those
actions' other callers (clearAllMesh, setMeshOnly, the pre-send
balance check) keep their guaranteed-fresh, uncached reads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>