Skip to content

sync: ambient directories, receiving rules, and the gates that were red - #625

Open
alichherawalla wants to merge 386 commits into
mainfrom
release/sync-cross-platform
Open

sync: ambient directories, receiving rules, and the gates that were red#625
alichherawalla wants to merge 386 commits into
mainfrom
release/sync-cross-platform

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Your phone and your Mac become one device you can trust: files, chats, clipboard and models move between them by themselves, over your own network, and nothing leaves either device that you did not agree to.

293 commits, 335 files, +39,567 / -10,225.

What this gives you

Your devices find each other and stay paired. Discovery over the LAN with a persistent device name, a code you confirm on the other screen, and pairings that survive an app restart, a reinstall and an OS upgrade. Android stops advertising a LAN route it cannot actually dial, so a row never says reachable when it is not.

Files arrive on their own, but only the ones you chose. Screenshots and downloads share ambiently per source and per destination, with "auto", "ask me" and "off" obeyed exactly. Media access is requested at the moment you turn screenshot sharing on, not at launch. A synced files library holds what arrived, attributed to the device that sent it, and tells "we have this" apart from "we know about this" so Open and Share are never offered on a file that is gone.

The clipboard follows you, opt-in. Copy on one device, paste on the other, with the origin device preserved so you can see where a snippet came from. Bridged natively on Android, with guided access on iOS.

Chats and projects converge. A message that arrives from another device shows up when it arrives, not when something else happens to reload. Received messages keep the tools they were offered. Project knowledge bases accept pasted text directly.

Models transfer between devices. A model you already downloaded on one device can be sent to the other and is admitted as a real installed model, checksum-verified, rather than re-downloaded over cellular.

You decide what lands. Per-device receiving rules, a clipboard gate, and rules that are cleared on unpair so an id reused by a future device never inherits a decision you made about a different one.

Licensing and the device cap. Entitlement bootstraps during pairing, revalidates on launch, normalises a pasted key, and replaces the least-recently-used seat when you hit the cap instead of refusing.

Verification

  • 617 suites, 8,569 tests passing (8 skipped), full jest --coverage --forceExit --runInBand.
  • Android unit tests (:app:testDebugUnitTest) and iOS tests run in CI.
  • Two real phones, driven over adb and WebDriverAgent: __tests__/device/meshPairing.e2e.mjs pairs an iPhone and an Android device on the real network and asserts each one shows the other, and that neither claims a relationship the other denies.
  • Coverage floors: src at 80 on every metric, ./pro at 80 on statements/functions/lines and 79 on branches, which is where pro genuinely measures (79.44% of ~4,700 branches). Reaching 80 on branches needs about 78 more covered branches in ttsService, mcp/oauth metadata and knowledgeDocumentSyncService; that is real work, not a rounding nudge, so the floor is pinned just under the measured value rather than at a number nothing satisfies.

CI, and why it was red

Four separate causes, none of them a failing test:

  1. Every PR in all five repos was auto-closed when the branch rename deleted the old head refs. The red checks were dead runs from before the rename. This PR replaces sync: ambient directories, receiving rules, and the gates that were red #624.
  2. Android Lint was 68 of the job's 90 minutes. ESLint itself takes 36 seconds; npm run lint chained ./gradlew :app:lintDebug, which cold-configures every React Native native module on a macOS runner. CI now runs npx eslint .; Android Lint is a local pre-merge gate, the same call this workflow already documents for the Android build. Android unit tests still run here. Expect roughly 22 minutes instead of 90.
  3. Coverage thresholds failing by fractions of a point on a run where all 8,557 tests passed. See the floors above.
  4. A cross-suite timer leak failed exactly one rendered suite per run, under a different name each time, and passed in isolation every time. A 50ms token-buffer flush outlived its suite and fired inside the next one after jest.resetModules(). The harness now stops in-flight generation on teardown; the whole integration and rntl set (2,236 tests) then passes repeatedly with zero failures.

One ci job reports for this repo, matching the other three.

Tests worth calling out

The doctrine here is integration over mocks, with fakes only at genuine device boundaries. Every mock in the sync test surface of this release is a real boundary: native TCP, native mDNS, the filesystem, the keychain, the document picker. There are no mocks of our own code in the new sync tests.

Where older suites did mock our own code, they were deleted rather than repaired, and the journeys they claimed were rewritten against the real thing:

  • generationFlow.test.ts fed onStream itself, so the test was the model. 12 of its 15 cases were already covered by rendered suites; the two that were not are now real, asserted at the native engine.
  • imageGenerationFlow.test.ts was 60 tests over a stubbed image generator, six of them named after line numbers. What it never covered is the window a user actually sits in: STOP reaching the native generator, progress moving on the card, and a second send not starting a second diffusion.
  • ragFlow.test.ts mocked the DATABASE by matching SQL strings. Retrieval "found" whatever the matcher returned. Prompt-budget truncation and project scoping are now asserted over a real in-memory SQLite, including that a search never returns another project's documents.

Three sync modules that had no test at all are now covered: mesh residency policy (a refused foreground service must not fail sync start), availableSyncIds, and forgetDeviceRules.

Known gaps, recorded not hidden

docs/GAPS_BACKLOG.md carries the open items, including: ejecting a model mid-reply unloads the engine without stopping the generation (measured: native unloadModel 1, native stopGeneration 0); the ChatScreen journeys left uncovered by deleting a 155-case mockist suite, with the measured 8-point drop and the four named journeys; and the image-generation journeys not yet rewritten.

Greptile Summary

This release substantially expands cross-device synchronization, pairing, receiving controls, licensing, model transfer, clipboard sharing, and chat convergence while consolidating CI verification.

  • Adds native Android and iOS synchronization bridges for discovery, directories, screenshots, clipboard, and encrypted blob transfer.
  • Adds ambient and explicit file sharing, receiving preferences, transfer history, shared-file materialization, and model-package admission.
  • Adds persistent pairing identity, entitlement lifecycle handling, device-cap replacement, and device-specific rule cleanup.
  • Reworks chat, project knowledge, RAG, and model state flows with extensive integration, native-boundary, and device tests.
  • Consolidates lint, type-checking, architecture checks, Jest, Android tests, and iOS tests into one CI job.

Confidence Score: 5/5

The PR appears safe to merge because no eligible blocking failure or outstanding prior finding is established.

No blocking failure remains.

Important Files Changed

Filename Overview
src/services/sync/nativeSync.ts Adds the central native synchronization boundary and orchestration used by the new device-sync capabilities.
src/services/sync/mutation.ts Adds synchronization mutation handling for applying and propagating cross-device state changes.
src/services/sync/nativeProximity.ts Adds the React Native proximity and pairing bridge used for device discovery and trusted-peer communication.
android/app/src/main/java/ai/offgridmobile/sync/BlobServer.kt Implements Android-side encrypted blob reception and transfer lifecycle handling.
ios/BlobChannelServer.swift Implements the corresponding iOS blob-transfer server and payload reception path.
src/services/proLicenseService.ts Reworks entitlement validation and device-cap licensing behavior used during startup and pairing.
src/stores/chatStore.ts Extends chat state and persistence behavior to support convergent remote messages and synchronized context.
.github/workflows/ci.yml Consolidates repository gates into one macOS job and provisions the private Pro and shared workspace dependencies.

Sequence Diagram

sequenceDiagram
    participant A as Sending device
    participant D as Discovery and pairing
    participant R as Receiving rules
    participant T as Encrypted transfer
    participant B as Receiving device

    A->>D: Advertise stable identity
    B->>D: Discover and confirm pairing code
    D-->>A: Persist trusted peer
    D-->>B: Persist trusted peer
    A->>R: Announce clipboard, file, chat, or model
    R->>R: Apply peer and content-specific policy
    alt Receiving allowed
        R->>T: Authorize transfer
        T->>B: Send encrypted payload
        B->>B: Verify checksum and materialize
        B-->>A: Record completion
    else Ask or off
        R-->>B: Prompt or suppress transfer
    end
Loading

Reviews (3): Last reviewed commit: "fix(sync): make a failed receive discard..." | Re-trigger Greptile

getOrCreateLocalDevice generated and persisted a random device id, competing with the
protected installation fingerprint that membership, pairing and the licensed-installation
roster key on. Public core now owns display facts only:

- getLocalDeviceProfile returns name/platform/version and no id, so nothing outside
  private Pro can introduce a competing identity
- readLegacyLocalDeviceId / clearLegacyLocalDeviceId let Pro migrate a persisted op-log
  onto the canonical identity once, then retire the old key

Deletes the dev sync harness: disabled (SYNC_DEV_HARNESS = false), superseded by the Pro
Sync UI per its own comment, and a second caller that minted an identity.

App.tsx also carries an unrelated in-flight initializeApp hunk that was already
uncommitted in the tree; the harness removal cannot be split from it.
Only WorkManager's model-download service was declared, so backgrounding Android suspended
the process: mDNS discovery stopped, the TCP listener closed and in-flight transfers died
while the peer still displayed this device as connected.

One TS contract, both platforms satisfy it, and the capability gap is declared DATA rather
than a Platform.OS branch in any caller:

- Android MeshResidencyService is a dataSync foreground service, so residency is unbounded
  (survivesBackground: true, backgroundGraceSeconds: null) with a silent ongoing
  notification - background reachability is visible, never silent. START_STICKY brings the
  mesh back if the OS reclaims it, and invalidate() stops the service so a reload cannot
  leave a notification promising what JS can no longer provide.
- iOS reports survivesBackground: false with a ~30s grace, because iOS grants no indefinite
  background execution to an app like this. beginBackgroundTask only finishes work in
  flight. Claiming otherwise would put a "Connected" row on a peer's screen for a device
  that cannot answer.
- A build without the native module reports no residency instead of throwing, so the mesh
  still runs in the foreground and the UI can still tell the truth.

Kotlin compiles (compileDebugKotlin). Not yet verified on a physical device.
The existing manual gate is iOS/macOS only and 8/108 verified, with pairing 0/9,
discovery 0/9, membership 0/7 and persistence 0/5 - every subsystem the product brief
calls non-negotiable is unverified. It also has no Android axis.

PERSONAL_MESH_TEST_MATRIX.csv adds 42 rows with a column per platform, weighted to the
unverified areas and to cases that only exist on Android (first Android pair, NSD
advertise, background survival, reinstall identity, clipboard semantics without Universal
Clipboard). Rows are written so a pass means the UI told the truth, not just that data
moved.

GAPS_BACKLOG gets the first Personal Mesh section: three gaps closed in this pass (code
and wired, not device-verified) and six open, including Android reinstall orphaning a
seat, the total absence of key revocation/rotation, and two shared projections with zero
callers.
PM4 and PM6 were not gaps. PM4 is what auto-eviction of the least-active installation
plus user-driven device management already answers. PM6 claimed two shared projections
had no callers; both apps render them and the original grep had excluded shared/. The
real PM6 defect was the eviction copy, now fixed in shared.

Also records Android discovery/advertise and clipboard provenance as verified-correct
by inspection, so nobody re-derives them.
Product decision: we do not support revoking or rotating a license key. A compromised
key is the user's loss. Device eviction stays the only removal mechanism, so this is
not a gap and should not be re-opened as one.
Ambient sharing only gives the sender a policy. The receiving device has no say: the sink
registry dispatches on MIME type alone and every factory builds a sink unconditionally, so
a paired device can push a multi-gigabyte model with no prompt and no per-device rule. The
refusal path already exists (createSink returning null sends file_reject) and needs a
setting behind it.
Product decision: no receive-side prompt. AirDrop auto-accepts between devices on one
Apple ID, and Personal Mesh is same-owner-only, so consent would be friction with no
threat model. The admitIncoming gate stays in shared but unwired.
Core now announces that the in-progress assistant reply grew, and that generation ended,
through the hook registry - the same seam voice mode already uses. Free builds do nothing.

emitStreamingUpdate is defined once because both the answer and reasoning appenders fire
it and both must report the same cumulative pair; a frame carrying one without the other
would render a preview that disagrees with the device generating it.

The complete signal fires after addMessage, so the durable record is already on its way
when peers retire the preview - otherwise the reply would blink out and back in. Cancel
goes through the same signal, or a peer would hold a half-written answer for a message
that will never arrive.
PM10: no Kotlin screenshot watcher exists, so ScreenshotSyncSource's observe() throws and
is swallowed - automatic screenshot sharing cannot work on Android. iOS has the module,
Android does not, which is the platform-parity rule violated directly.

PM11: the 'for your safety, share another folder' message is Android's own SAF picker
refusing the Download directory, which Android 11+ never grants. Our bug is offering
Downloads through the folder picker at all; the Android path is MediaStore.Downloads,
which needs no SAF grant.
The receiving half of chat streaming. A reply being written on one device now appears on
the others token by token, instead of only when it completes.

Remote previews render through the SAME synthetic-streaming-message path as the local
reply, so there is one bubble implementation rather than a second renderer that would
drift. Their ids are namespaced per generation, so the list keeps one row per in-flight
reply and never collides with the local 'streaming' row - a device can be generating while
a peer generates too.

remoteChatStreamStore is a read-only projection Pro writes into; it holds nothing durable,
since the finished message still arrives through the op-log and the preview vanishes. Free
builds never write to it, so the screen behaves exactly as before.

The narrowing lives in its own hook rather than inline, which also keeps useChatScreen
under the max-lines-per-function gate.
Home took the store's first four conversations with no ordering at all, so "Recent" could
list older chats than the ones just used, and disagreed with both the Chats list and
desktop.

The ordering rule now lives in one place and both surfaces call it, instead of the Chats
list holding the only copy inline. It sorts on updatedAt, which the message actions already
maintain, so a message synced from another device reorders the list the same way a local
one does.
The pro-side trace showed the service starting and then nothing, so the break is upstream of
it. This logs once per stream from the core emitter, which separates three faults that look
identical from the Pro side: the emitter never runs at all, it runs with no conversation
bound (NONE-BAILING), or it runs and no hook receives it.
The two streaming hooks are gone. Pro subscribes to the chat store, so a new
code path that changes streaming state cannot forget to notify it - which is the
defect the hooks had: setStreamingMessage and the tool-loop reset never emitted.
What landed, what is verified versus code-only, the remaining work with file:line
pointers, environment gotchas, and the copy/design rules enforced this session.
…to it

Sync sharing was three long open sections on one page. Model Settings already
solves this with an uppercase title, a chevron and content below - but hand-rolled
per screen, so this lands it as one component in core and uses it for Sending,
Receiving and Ambient sharing. Ambient keeps its ACTIVE indicator in the header,
so the one thing worth knowing survives collapsing the section.
…iles

An active licence rendered a status card, a desktop link, then half a screen of
nothing. Empty space that says nothing is a bug, so the space now carries what the
licence opened: rows into Sync, Clipboard and your own tools, each gated on the
screen actually being registered so nothing advertises a dead end.

Also squares away two anti-patterns from the design philosophy: the 40px filled
circles behind the pillar and desktop icons were decorative tiles, and round ones
at that.
The narrowing and row-shaping rule now comes from @offgrid/sync, and the store
holds shared sync's own preview type instead of a lossy copy of it, so the Mac
appends identical rows.
The bar animated width with useNativeDriver false, so every frame was computed on
the JS thread - the one thread busy loading a model for the entire time the bar is
on screen. It stuttered, which reads as a hang when nothing is wrong. Now a
full-width bar slides in from the left inside the clipping track, on the native
driver, so it stays smooth however busy JS is. Also drops an animated layout
property, which the design philosophy rules out.
Tapping "Settings changed - tap to reload model" ran an async callback with no
catch. If the unload or the load rejected, it ended as an unhandled rejection: the
banner stayed, the model never came back, and the tap read as a dead button. The
reload now lives in its own module, logs its outcome, and surfaces a failure as an
alert instead of nothing.
loadedSettings is persisted, so it outlives the model. With nothing selected the
"Settings changed - tap to reload model" banner still appeared and its tap
correctly refused - a dead button, which is what it looked like on the device
("[ModelReload] ignored: modelId=none"). The offer and the action now come from one
predicate so they cannot disagree.

Visibility only. This flag has exactly one consumer, the banner in
ChatMessageArea, and gates nothing in send, generation, or model loading.
The selected id is persisted; the downloaded list is rebuilt by scanning the models
directory at launch. When a rebuild produces a different id for the same file, an
exact-id lookup finds nothing and every surface answers "no model selected" while
the engine has that exact file loaded - a live model, a refused send, and "please
select a model" (device, 2026-07-31).

activeModelService.resolveSelectedTextModel is now the one answer, falling back to
the file the id ends with, because the file on disk is the durable identity. The
drift is reported once per id instead of swallowed. The rule is pure and lives
apart from the service (resolveModel + selectedTextModel), so it is testable
without the store.
…te copy

The chat screen re-derived "which model is active" with its own memo - the same
remote-first, then find-by-id rule useActiveTextModel already owned, including the
same id-drift bug. It now uses that hook, whose local branch delegates to
activeModelService, so a rebuilt id resolves instead of silently reading as "no
model selected".

Adds useActiveModelStatus: one subscription to the service for the selected /
loaded / loading snapshot, for the surfaces that render load state.
Tapping a row records a SELECTION - the load is deferred to the first message - but
the sheet set its own loading flag on tap and cleared it only when the parent's
isLoading went false. That transition never came, so the row span forever: a
spinner for a load nothing was running (device, 2026-07-31).

Row state now derives from the active-model snapshot the service owns, through one
subscription (useActiveModelStatus) and one pure rule (loadingTextRowId), so the
sheet can no longer invent a load. The selected row is also resolved by the service,
so a rebuilt id still marks its row.
activeModelId is the selection; lastTextModelId is only written when a model is
picked from a sheet. Three callers each decided the order for themselves, and the
chat's deferred-load path read lastTextModelId ALONE - so after a load that came
from anywhere else it loaded the older model while the newer one sat selected on
screen (device: picked Qwen 3.5, loaded SmolVLM-256M).

activeModelService.selectedTextModelId is now the one answer, used by the chat load
path, image generation and the preloader. The rule itself is pure
(selectedTextModelIdOf), and the snapshot projection moved out of the service
(snapshot.ts) so each piece owns one thing.
…ws a bare 1

Real slider, real hook, real hardwareService, real topology reader, real rule.
Only react-native-fs is faked - the native line - and it serves the EXACT sysfs
values read off the devices, so the count on screen is emergent from the
kernel's topology rather than programmed by the test.

Four real shapes, because the rule has to hold across them and a single fixture
would leave the others at zero coverage: the CPH2707's three tiers (380x3,
873x4, 1024x1) which is where the bug was found, classic 4+4 big.LITTLE, a 2+6,
and a uniform 8. Each asserts the performance-core count IS shown and that the
bare "1" is not - the slider minimum standing in for an unset value, which is
what the screen showed while the engine ran 6.

A fifth case covers the other side: a number you picked stays yours and stops
being called automatic.
…read detection

Brings the mobile fixes onto the release branch:
- the streaming Enhanced prompt card renders as a card, not raw markup
- a turn's modality is recorded at dispatch, so a cancelled image turn resends
  as an image instead of falling back to text
- the chat loading bar uses our own indicator, not the platform spinner that
  reads as a retry glyph on Android
- auto CPU threads follow the performance cluster read from the kernel topology,
  and the slider says Auto instead of 1
Every advanced text-generation setting renders as the same pill row, and it
lived inside the file that holds all of them. A new control could not import
just the primitive without importing every sibling, and the file had reached
its size budget. Its own module: no cycle, and textGenAdvancedSections drops
from 502 lines to 254.
The model drafts several tokens per step and verifies them in one pass, so a
turn finishes in fewer forward passes. The win is wall-clock only: verified
tokens are exactly the tokens the model would have produced anyway.

One setting (speculativeDecoding), one param mapping (buildModelParams, the
single place settings become engine params), one control - which the in-chat
modal and Settings > Model Settings already share through
components/settings/textGenAdvancedSections, so both surfaces get it from one
implementation.

Set at context creation, not per completion: it changes how llama.cpp builds
the graph. No draft model is named - MTP models carry their own draft layers
and llama.rn falls back to them - so a model without MTP weights simply never
drafts.
llama.cpp b9769 to b10256, about 500 commits. What matters here:

- 0.12.7 adds KV cache reuse across chat turns for recurrent/hybrid AND
  multimodal models - a real win on a Qwen plus mmproj setup, which is what we
  run on device.
- 0.12.9 fixes cancelled parallel completions, the class of bug where stopping a
  generation leaves state behind.
- 0.12.6 fixes UTF-8 generation and token trimming.

Nothing in the notes touches Hexagon/HTP, so the NPU path verified on 0.12.5 is
not expected to change.

Ships prebuilt native binaries: needs pod install on iOS and a Gradle rebuild on
Android. A Metro reload is NOT enough.
Nobody goes looking for a setting whose benefit they have not been told about,
so the offer comes to the chat instead: this model can draft several tokens per
step, turn it on. It appears only for models that declare MTP layers and stays
silent for the rest, rather than advertising a speed-up most models cannot
deliver.

Tapping it enables the setting and reloads through chat.handleReloadTextModel -
the same seam the reload banner uses, so there is one owner of "reload the text
model". The action line says it will reload, because an unannounced reload
mid-conversation reads as a hang.

Detection splits the same way as the CPU topology: mtpSupport owns the pure rule
over GGUF metadata, mtpDetection owns the header read (loadLlamaModelInfo reads
the header only, no model load) and caches per path.

The rule matches a MARKER (nextn/mtp/multi_token) with a layer count above zero
rather than one exact key, because every family names it differently and a false
negative is invisible - you just never see the card. A false positive costs
nothing: the engine never drafts.
Real card, real store, real detection service, real rule. Only llama.rn's GGUF
header read is faked - the native line - and the negative fixture is the key set
dumped off the device from Qwen3.5-0.8B-Q4_K_M, so "no card" is a verdict about
a real model rather than an empty object standing in for one.

Five cases: silent when the build carries no draft layers, silent when it names
the module with zero layers, offered when it can actually use it with the reload
stated before the tap, tapping both enables the setting AND reloads and then the
offer goes away, and dismissing changes nothing.

Each case installs its own model FILE because the probe caches per path, as it
does in the app - re-reading one path with different answers is something no
device would ever do, and doing it in the test was what made four of these fail
against working code.

jest.setup gains loadLlamaModelInfo on the shared llama.rn fake.
Turning speculative decoding on wedged the engine: every completion failed with
"Exception in host function. Context is busy" on iOS, and the chat was unusable
on both platforms.

My own bad assumption. I wrote that the flag was safe to leave on for models
that cannot use it, because llama.rn's docs read that way. The device says
otherwise - passing speculative to a model with no draft layers leaves the
context in a state where nothing completes.

The setting still states the user's preference and stays ON. resolveSpeculative
now decides whether the engine can honour it, from the model's own GGUF
metadata: enabled only when the model declares draft layers. So the preference
survives model switches and engages by itself the moment a real MTP build is
loaded, instead of being something the user has to remember to re-enable.

resolveGpuBackend tightened to an arrow to keep the file inside its size budget.
… portable settings

Turning MTP on in settings changed nothing and said nothing: speculative
decoding is fixed when llama.cpp builds the graph, so it applies on the NEXT
load, and it was missing from the pending-settings rule that drives the reload
banner. The next reply came back at the same tok/s with no prompt to reload.

It now joins the rule, and both load snapshots record it, so the banner appears
when it differs from what the loaded context was built with - and disappears
once reloaded.

Sync: the portable generation settings join the model_setting map (thinking,
image steps/guidance/size, prompt enhancement, image mode, auto-detect method).
Hardware choices deliberately stay out and are documented as such - an NPU
backend or a thread count tuned for one device is wrong on another. threads and
gpuLayers already sync and arguably belong in that exclusion too; called out in
the code rather than changed quietly, since it would alter existing meshes.
… them

A flat bar carries a top border edge-to-edge. Rendering the 'Settings changed'
bar above the rounded advice cards drew a hard rule across the card's top
corners, so the card read as clipped. The stack between the list and the
composer is now ordered by shape: rounded cards first, flat full-bleed bars
directly above the composer.
serializeMessageContext admitted reasoning, tools and timing but dropped
isSystemInfo, so the fact never left the device that wrote it.
…ilent

The scan reported an empty network while Off Grid AI Desktop was on it. The
500ms probe deadline was shorter than a real Wi-Fi round trip: that gateway
answered /v1/models in 133, 285, 292, 454 and 676ms across five tries from an
idle phone, so half of them lost the race before a sweep's own concurrency
stretched them further.

Two seconds now, paid for by sweeping the three providers together instead of
one after another - they wait on the network, not the CPU, so the sequence was
spending three times the wall clock doing the same waiting.
The screen was built from scratch instead of from the parts the app already
has, so it read as a different product: its own header, its own filled pill
buttons, its own cards. It now uses ScreenHeader, Button and the bordered card
the advice cards use.

Eleven text styles carried a hardcoded fontSize and no fontFamily, so every one
of them rendered in the platform sans while the app around them is mono, and
six were weight 600 against a bar of 400. All of it is on TYPOGRAPHY tokens
now, and the magic spacing numbers are on SPACING.

UX, in the same pass:
- One question per control. Scan and Add sit side by side as two ways to do one
  job, instead of a filled button, a grey button and a link stacked in a column.
- The scan says what it did in place, naming the ports it tried, rather than
  raising a dialog that says 'No Servers Found' and leaves you nothing to act on.
- The auto-discover line describes the switch's state; it used to read 'Off by
  default' while the switch was on.
- Tapping a server chooses it. The store has always had an active server and
  this screen never let you set one.
- Remove is the only coloured action and sits apart from Test and Edit.
- The About card is gone: it repeated the empty state almost word for word, and
  the same desktop link appeared twice on one screen.
'Model loaded: Qwythos-9B-v2-GGUF (3.1s)' is a fact about the machine that
loaded it. Synced verbatim it became a false statement everywhere else: the
phone showed that it had loaded a model it never loaded, at a speed it never
reached. It is now shown only on the device that wrote it. Nothing is deleted
and the notice still syncs.

A device's own notices carry provenance too, once the record round-trips, so
the presence of provenance is not the test - the origin is compared with this
device. Pro owns the mesh identity and core cannot import Pro, so the id
arrives through a core store Pro writes.

Collapsed to one owner while fixing it, because the rule had five callers:
the chat thread and four list screens each derived the last message by hand,
so hiding a message in the thread would have left it quoted on the home
screen as the last thing said. visibleMessages answers 'what does the user
see' once; conversationPreviewLine answers 'how does a row read' once.

Three of those lists also built the preview by hand ('You: ' + raw content)
while ChatsList used the shared rule, so a pasted code block blew a row's
height apart and a long reply was never cut - in some lists and not others.
All four now read the same, and the same as the Mac.
Android runs every fetch on one shared OkHttp dispatcher, and that dispatcher
runs 64 requests at a time. It queues the rest. A queued probe still counts
against its own deadline, because the abort timer starts when we call fetch
and not when the request leaves the phone. Sweeping three providers at once
put 150 probes on that dispatcher, so the extra ones expired in the queue and
a network with a server on it reported as empty.

iOS hid the fault: it caps connections per host, and every probe is a
different host, so the same code found the server there.

One queue for the whole scan now, 48 in flight. Workers pull the next probe as
a slot frees, so a silent address no longer holds 49 slots idle for its full
deadline the way fixed batches did - which is what pays for a deadline long
enough to be correct.

Off Grid's own port is probed first, so the server this app exists to find is
reported earliest.

Verified: found immediately on both Android and iOS.
'Looking for servers on your Wi-Fi' was drawn as 'Looking for servers on
your', with the width it needed still empty to the right.

Callers style this line with TYPOGRAPHY tokens, which name Menlo, and Menlo
does not exist on Android. Android falls back to another face and then
synthesises the italic this component asked for, so it measures the line with
one typeface and draws it with another, and the tail is cut. iOS has Menlo,
measures and draws the same face, and read correctly - which is why only one
platform showed it.

The italic is gone: the terminal type does not use one. The line may also
shrink now, so a long caller wraps rather than running past the edge.
The sheet named no design token at all: fourteen hardcoded font sizes with no
family, seven weights of 500 or 600 against a bar of 400, and about thirty
magic spacings. So it drew itself in the platform sans, heavier than anything
around it, and read as another product's form.

- Type on TYPOGRAPHY, spacing on SPACING, no weight above 400.
- Fields are bordered like every other surface here, not filled slabs with a
  12pt radius.
- Test and Save use the app's Button instead of two more hand-built pills, and
  neither swaps its label for the platform spinner - that glyph reads as a
  retry arrow on Android.
- The public-internet warning used an emoji, which the design system bans
  outright. It is a Feather icon now.

Copy follows the brand guide: no em dashes, and the warning says what actually
happens to your data rather than that a condition exists.
…e that lied

Deleted __tests__/rntl/screens/RemoteServersScreen.test.tsx. It mocked five of
our own modules - the theme, the modal, the alert, the manager and the
discovery service - and asserted toHaveBeenCalled fifteen times. Its mocked
theme supplied a blue accent, so it rendered a design system that does not
exist. All 23 rows stayed green through a screen carrying eleven hardcoded
font sizes and six weights over the bar, and through a scan that could not
find a server at all. They failed only when a string they had memorised
changed. The doctrine says delete a failing mockist test rather than repair
it.

Added scanFindsGatewayOnLan: mount the real screen, tap Scan network, and a
Mac serving the gateway on the same Wi-Fi turns up in the list at its address
with 'Added 1 server.' Fakes sit at the device boundary only - which address
this phone has, and the network itself - so the subnet maths, the worker pool,
the aggregation, the manager and the store all run. The second row pins that
the gateway port is asked of all 254 addresses, because a scan that never
reached the Mac's address is exactly what hid it.

Repaired the tests that were honest. scanNoServersNoPhantom keeps its guard,
now against the inline report rather than a dialog. Five more press by testID
instead of by label - each had been picking the modal's save with
getAllByText('Add Server')[length-1], an index that only worked because the
screen and the modal shared a label. They no longer do.
Deleted __tests__/rntl/components/RemoteServerModal.test.tsx. It mocked six of
our own modules, including the STORE - so it could not observe whether a server
ends up in your list, and asserted that a mock had been called instead.

The replacement drives the sheet from the real screen and covers only what
nothing else does: a malformed address is refused and leaves no half-made
server behind; the privacy warning appears for an address off your network,
stays away for one on it, and clears again rather than latching; and a rename
through Edit reaches the list as one row, not two.

The existing happy path (open, type, test, save, Connected) already lives in
remoteServerConnect, so it is not repeated here.
Kotlin wrote file.lastModified().toString() and the JS path wrote
Date.now().toString(). Both satisfy createdAt: string. Neither is a date. iOS wrote
ISO-8601 all along, which is why image sync worked from the iPhone and has never
once worked from an Android phone.

Both now write ISO-8601, so the two native implementations of one contract agree.
The producers are corrected, but a phone that has generated images already holds
the old value and nothing else would ever rewrite it. The gallery showed those as
an invalid date and no peer would accept them.
The Enhanced prompt card is CONSTRUCTED by the app, not streamed by a model. It
carries its own label and its own body inside the content. A separate reasoning
channel was preferred over both, so the card rendered as 'Thinking...' with the
single word 'Generated' in it - whatever the model happened to be mid-sentence on
while the card was being written.

A labelled block now wins. An unlabelled one still defers to the channel, which is
the ordinary model case and is unchanged.
getConversationContext sent message.content, which is the STORAGE form. So the
enhancement's context carried the enhancement's own card markup and the caption
the app writes under a finished picture. Four of the last six messages were ours.

Imitation beat instruction: the model emitted <think>__LABEL:Enhanced prompt__
token by token and returned 'Generated image for: "Draw a fox"' as its idea of an
enhanced prompt. A marker invented for the screen must never be model input.

App-authored assistant messages are dropped and the rest go through the one
display parse. The user's own turn states the request and is kept.
A probe that timed out falls back to '4096 context and nothing else', which is
stored exactly like a real answer and is indistinguishable from a model with no
features. One flaky moment at discovery hid the thinking toggle and stopped the
kwarg being sent, for the life of the install.

This phone holds two records for the same gateway - one with everything false, one
with everything true - which is what that looks like from the outside.

A record shaped like a failed probe is now UNKNOWN, not an answer, and is
re-discovered before it is believed.
A new install now QUEUES for an absent device, so the drop this test asserts is the
user's explicit choice and the test has to make it explicitly rather than inherit
it from the default.
BYTES_PER_GB, getMaxContextForDevice and getGpuLayersForDevice answer one question
- what will fit on THIS device - from the device's memory and nothing else. They
sat among the llama.rn context helpers, so a pure sizing rule read as an engine
detail and every caller of BYTES_PER_GB pulled in the native binding.

Re-exported from llmHelpers, so no call site changes and there is still one place
each rule is defined.
A new install queues for a device that is away instead of dropping the file, so
the persistence tests state that and say why.

An attachment with no usable declared type follows the FILE rather than falling
straight to a document - that is what hung a synced generated image in the chat as
a file row. Added the case that locks the other half of the rule: a PDF with no
declared type is still a document.
The tour is gone from every surface: 26 AttachStep wrappers, the provider in
AppNavigator, the 336-line step config, the pending-spotlight state module, the
per-screen effects that consumed it on mount, the shownSpotlights ledger in the
app store, the react-native-spotlight-tour dependency, and 9 test files.

The onboarding CHECKLIST stays, because it is a different thing: a list of things
worth trying. Tapping a step used to close the sheet, queue a pending spotlight,
navigate, and fire a timed goTo that several screens had to cooperate with. It now
closes the sheet and goes to the right tab, which is the part anyone wanted. That
destination map moved to checklistNavigation, next to the checklist it serves
rather than inside a tour config that no longer exists.
Removing the tour left three dead exports behind. ENHANCED_PROMPT_LABEL and
lastVisibleMessage are used only inside their own modules, and react-dom was
pulled in by the spotlight tests alone.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant