Sync feedback: Mobile runtime, clipboard, and model fixes - #628
Open
alichherawalla wants to merge 229 commits into
Open
Sync feedback: Mobile runtime, clipboard, and model fixes#628alichherawalla wants to merge 229 commits into
alichherawalla wants to merge 229 commits into
Conversation
…droid From Android 10 the platform refuses `primaryClip` to an app that is not on screen. The change listener still fires, so the app learned that a copy HAPPENED and never what it was - and the `?: return` on the read dropped it. That is why nothing copied outside Off Grid ever reached a paired device: the transport was fine and there was simply nothing to send. Accessibility supplies the missing half. A service reports text selections and nothing else - its config declares `typeViewTextSelectionChanged` alone, with no window-content access - and `ClipboardSelectionMemory` holds one selection for 30s, consumed by one copy, so a stale selection can never be published as new. A clip this app CAN read still wins, and clears the memory. The service is off until the user turns it on in Settings, and nothing here asks them to.
… a background copy A fact the platform owns, asked rather than remembered. An older native build that cannot answer is treated as capable, not as denied: reading silence as "off" would nag the user to enable something this build cannot even see.
Nine defects as reported, separated from the two that were not defects and from what is confirmed working. Two carry a cause already: the Android clipboard (the platform refuses a background read) and the desktop mDNS bind (one dead interface takes the whole advertisement down). Muse Glimmer is confirmed rather than guessed - `muse-glimmer` appears in zero files of the llama.cpp that llama.rn 0.12.9 bundles, against 35 mentioning `qwen3`.
…g to migrate Checked the history of DEFAULT_RECEIVE_POLICY rather than assuming: `disabledCategories: []` from its first commit. A stored policy with categories off is therefore a choice made on that machine, not an inheritance - and a migration that cleared it would silently re-enable something a user switched off. Recorded as not-fixed with the reason, and with the one question that would turn it into a real defect.
…ty switch finds the model busy The seat one is the sharper of the two: the mesh already reclaims the least attributable seat before evicting a live device, so a reinstall leaving a ghost behind means that policy did not run on this path. A user should never have to ask for a seat their own phone vacated. His requests are recorded separately from his defects, including the hosted-GPU one, which deserves a plain answer rather than an open question - the promise is that data stays on the user's devices.
…t governs All business logic in shared/sync, hosts as consumers that supply facts and decide nothing. Everything that moves is an item on one durable queue, because a device offline means "not yet" and never "lost". And the tell, written down: "X works here and Y does not" means two code paths doing one job. Four of today's defects are exactly that shape, so the first question for each is where the one owner is - not which side to patch.
`constants`, `appStore` and `projectStore` each carried their own default system prompt - three different texts for one idea, all opening with the same sentence. It matters beyond tidiness. `systemPrompt` is a SYNCED model setting, so whichever copy a device happens to hold is the one that travels to its peers. A report of a desktop reply opening with "A helpful AI assistant running locally on your device" can only have come from a phone, because that sentence exists nowhere else - and with three sources there was no single thing to point at.
The Windows preview is fixed and says how. The persona leak is traced to a real route - systemPrompt is a synced setting and that sentence exists only in mobile - but the value has since been overwritten, so it is recorded as not-fixed with the query to run before touching settings next time. The web-search complaint did not reproduce: that chip renders collapsed. Recorded as needing a screenshot rather than left implying a fix.
…patched yet The seat: reclaim is gated to the cap, and `isUnclaimedSeat` only names a seat with NO syncDeviceId - his ghost has one, so it is orphaned rather than unclaimed. Acting on "no live device answers to this seat" from one node would evict a device that is merely offline or paired elsewhere. It needs an identity that survives reinstall, which is a product decision. The busy error: the send is refused after a 15s `waitForIdle`, while this codebase documents a 74s CPU prefill in two places. So a healthy prefill reads as busy, and Pat's slowness is the same prefill from the other side. The right fix waits on progress rather than elapsed time, and wants a device round first.
…igrate The objection to a migration was protecting choices already in the field. There is no field. What has to be right is a FRESH install, and that is proven: the default accepts everything, and the two categories added today inherit it - asserted in receive-category-coverage.test.mjs. Anurag's Mac is a dev profile whose categories were switched off during earlier testing. A fact about that machine, not about the product.
Anurag tested the current build, where sync ships for the first time - so his section has no installed base behind it. Pat and the Muse Glimmer report are about previously shipped releases, and the app DOES have an installed base even though sync does not. The distinction decides what "nothing to migrate" may be used for: receive policies, yes; a paying user's licence seat, no.
One section per fix, each naming the exact action, the expected result, and the old symptom - so a partial fix cannot pass as a whole one. The re-registered-peer case and the dead-interface case are called out because they are the conditions that produced the reports, and neither happens by accident. Also lists what is NOT fixed, so nobody spends time testing for a fix that is deliberately waiting.
My first cut hid a cancelled attempt. The mobile integration journey disproved the premise by passing without it: it cancels, reads "Pairing cancelled", retries and pairs. Retry-after-cancel already works from waiting_for_confirmation, and the confirmation is wanted. Kept the fold-by-id, which is a real fix for a second route to the same stuck sheet. The untested difference is order: Anurag cancelled an attempt that had already FAILED, and a terminal attempt may have nothing left to cancel. The plan now asks for that sequence instead of claiming a fix.
Pinned exactly rather than caretted: a caret on a prerelease resolves unpredictably, and this is a release candidate whose own CI badge reads failing upstream - so the version we ship should be the version we chose. What it buys: the bundled llama.cpp knows `nemotron`, `nemotron_h` and `nemotron_h_moe`, so Nemotron 3.5 loads. What it does NOT buy: `muse-glimmer` appears in zero files of its cpp/, against 48 mentioning qwen3 - so Muse Glimmer still cannot load on mobile and no setting will change that. The Hexagon kernels already in this repo are byte-identical to the ones this version ships, so the assets need no change.
It implemented the OLD positional API and asserted guide tokens were forwarded - behaviour 0.13 removed. A boundary double that lags the runtime proves the engine against an API that no longer exists. Now: one options object, and the assertion is that we send NO guide tokens, which is the actual guarantee.
…ot the phone 30B, Apache 2.0, multimodal with a dedicated perception encoder. Over 55 GB at full precision, under 20 GB at 4-bit, needing a 24-32 GB envelope on "Mac or PC with a single consumer GPU". That reframes the report: no phone has that envelope, so the mobile attempt could not have succeeded whatever the runtime. The llama.rn gap is real and secondary. Desktop is where it is viable, and b10369 already knows the architecture.
…elease Mobile is on 0.13.0-rc.0, which gets Nemotron 3.5 and not Muse Glimmer. PR #379 upstream syncs to b10362 and names Muse Glimmer, but it is open - and a merge alone is not enough, because the prebuilt native artefacts come from the matching release. So: check again immediately before the release, bump if it landed, otherwise next release. Desktop has it today via b10369.
…rom the plan The clipboard fix is the headline item from Anurag's list and had no section at all - including the Accessibility grant, which is a permission nobody has granted before and the only new user-facing flow in this branch. The llama.rn 0.13 bump had none either, and it is the riskiest change here: a release candidate that moved the TTS API, so OuteTTS speech is the first thing to listen to. Also corrects the branch name in the header.
…rt the app
The app died three seconds after every launch, on every launch. `RNFS.stat` builds its result
dictionary and inserts the file type UNGUARDED:
@{ @"ctime": …, @"mtime": …, @"size": …, @"type": [attrs objectForKey:NSURLFileResourceTypeKey] }
iOS omits a key it cannot determine rather than failing, so `type` arrives nil and NSDictionary raises
`NSInvalidArgumentException: attempt to insert nil object from objects[3]`. The size directly above it
has a nil guard. The startup model scan stats every stored path, and an absolute container path goes
stale on reinstall - so a scan of the user's own models killed the process.
A JS try/catch cannot save this. The exception is raised on the module's own queue and rethrown
natively, so the process is gone before any promise settles, which is why `RNFS.stat(p).catch(…)` reads
as safe in 22 places and is safe in none.
`readDir` answers the same question and cannot fail that way: it guards nil attributes, defaults a
missing size, and derives the type from booleans. This asks the PARENT for the entry instead of asking
the path about itself, and returns null for a missing file - the ANSWER, not an error. Bytes come
through the existing size rule rather than a second conversion.
…t there A plain text message failed with "File does not exist or cannot be opened" because PHOTOS from earlier turns pointed into app containers that no longer exist. The runtime refuses the whole turn over one bad media path, and the model reads images from the entire conversation, not just the message sent. The existence check lived in `completion` - one caller of three. The tool path and the capped-token path converted messages directly, so stale attachments reached llama.rn there. A guard a caller has to remember is a guard the next caller forgets, so it now lives in the one conversion from our messages to model input, and no path can skip it. `isModelVisibleImage` is the single rule for what the model may see: not pending, and has a URI. Six call sites each filtered `type === 'image'` by hand, and a rule about model input has to hold at all of them or it holds nowhere. An announced-but-unarrived attachment has an empty URI, so without this the loader row became a media path pointing at nothing. A file still arriving is also no longer logged as "file gone" - saying a transfer in flight was deleted sends the next reader looking for something that never happened.
A synced file is announced before it is sent, and the gap showed nothing at all - so a generated image on its way from another device was indistinguishable from one that was never coming, and the only way to learn which was to restart the app. The row renders the three-dot loader and the real file name from the announcement. Checked BEFORE every other branch, because a pending attachment has no local file and each branch below reads `uri`. The loader is the shared one, imported directly rather than through the barrel.
The animation had two homes: inside ThinkingIndicator, and a platform ActivityIndicator inside Button. A ring spinner on a button reads as a retry glyph rather than work in progress, so pairing a device and sharing a file both looked like they had failed the moment they started. Every busy state now renders the one component, and a button does not change height when it flips to loading.
`RNFS.stat` and `readDir` report a size as a NUMBER on one platform and a STRING on the other. Ten call sites had each written their own ternary for that - ten chances to get a byte count wrong in a place the user reads it: a size, a free-space check, a "does this file match its manifest" guard.
…eeds it A vision model transferred from another device can arrive carrying its vision tag and no projector: it advertises sight it does not have, the composer refuses the photo, and nothing on screen explains why. The only repair lived in a screen the user had no reason to open, and it answered with a raw 401. The chat now offers the repair where the refusal happens, and both surfaces read one message rule so they cannot describe the same model differently. A model with no upstream says so - an imported model has no repo to fetch from, and saying that is correct. Several matching repos refuse rather than guess: a projector from a different quantisation loads and then reads images wrongly. A repo path is now read from where the model came from rather than rebuilt out of its display id.
…t they are Turning a download into a row was decided inside the hook, mixed with the orchestration around it, so the rule could not be read or exercised on its own.
The Vision badge came from a stored flag while the composer refused images - the two disagreed about the same model, which is what made a projector problem look like a chat bug. Sizes and busy states across these surfaces now read through the shared rules rather than being re-derived per screen.
The list, the preview and the project section each decided what a document was called and whether it was available, so the same file could read differently depending on where it was opened.
A message needs the id its peers know to survive a reload and to be matched against what a peer is already rendering.
…rately not wired Names each fix, what to look for, and what the failure looked like before, so a partial fix cannot pass as a whole one - plus the parts that are recorded but not yet acted on, so testing for them is not wasted effort.
The rules were spread across three files: a pure filter in the message builder, an existence check private to the service, and the same `type === 'image'` test written by hand at each call site. They are one rule read at two depths - what the attachment declares, and what the filesystem can back - so they live together now, and no caller restates either half. Also removes what the safe file reader made dead: size imports and local ternaries that no longer had a caller, which the lint gate caught. The pending row becomes its own component; every other branch in that map reads `uri` and this is the one case with none.
Both devices froze after a stop. The session correctly went to STOPPED, but nothing ever dispatched userStart - so a tap reached the recorder, the recorder asked whether it may listen, and the answer was permanently no. I wired the way in and forgot the way out. start() is where that ask arrives from every microphone in the app, so it dispatches there. The hands-free driver calls the recorder directly and is unaffected.
- End of turn (1/2/3/5s) and Listen again (0.5/1/2/3s) join Voice turns in settings; labels, choices and defaults come from @offgrid/speech. Each row shows only where it does something: end-of-turn outside tap mode, drain in hands-free. Read per turn / per hand-back, so a change applies immediately. - the session driver now obeys in both directions: a replay seizing the floor cancels an open recording (the one exit from listen the recorder does not drive itself) - gaps logged: tail silence still untrimmed, hand-back overhead unmeasured, none of this device-verified yet
…d air - useVoiceInput now hands out the controller-routed start/stop/cancel intents instead of its raw closures. The stop button was bypassing recordingController.stop(), so its userStop-in-hands-free decision never ran: the stop read as turnCaptured, transcribed to nothing, and hands-free re-armed - the bouncing stop that would not stay stopped. Every mic surface now gets userStart/userStop semantics by construction. - the silence endpoint captures the turn's trailing quiet as it stops watching, both stop paths pass it through one stopAndFinalise, and the trimmer cuts it with the lead-in - so a note's file matches its label (0:02 no longer plays seven seconds) and Whisper stops transcribing the person's chosen silence window.
…he catalogue Pointer bump for two pro commits: a completed turn with nothing to say now ends explicitly (image-turn hands-free deadlock), and ambient delivery rows apply sharedFileKindShowsInActivity so voice notes stop flooding Sync activity as Pending rows.
`recordingController.start()` both dispatched `userStart` - which the session driver obeys by opening the microphone - AND called `handlers.start()`. One tap therefore ran `startRealtimeTranscription` twice and entered the native `transcribeRealtime` a second time while the first session was still coming up. That is the "State: -100" collision (B12), and it never needed a double-tap. Captured from a call stack at the native boundary, not inferred. Four changes, and the last two belong together: - A synchronous in-flight latch in the recorder. The old guard read `isRecording` from a closure and `whisperService.isTranscribing` was only set after an await for permissions, so two asks fit inside that window and both cleared it. A ref needs no render. - `useVoiceSessionDriver` is EDGE-triggered, which is what its contract already claimed. Level-triggered, it opened a second recording on any notification that found the state at `listen`. - `voiceSession.dispatch` notifies on a phase change too, so the hero can say "Recording you now" instead of "Listening" over a turn being recorded. This is safe ONLY because of the edge trigger; make that driver level-triggered again and this notification becomes a second capture. - The session is built on first use. Reading the app store at module load ran `useAppStore.getState()` while that module was still initialising, where it is undefined - the same hazard on a cold start. Fixes the three red realtime tests: the double-start, the spinner that never became a live recording, and the blocked-then-freed dictation that left the composer empty. All three were this one defect. Code-verified only - the device pass is logged in docs/GAPS_BACKLOG.md.
…y sees
The controller derives its phase from the session and stores none of its own, so a `beforeEach` that
reset only the controller left the previous test's session standing - and a `start()` guarded on `idle`
then silently did nothing. Both are reset now, and `turnCaptured` is reached the way a person reaches it
rather than dispatched alone.
The subscriber sequence is four entries, not three: `userStart` opens the microphone before anyone has
spoken ('listening'), and `speechHeard` then moves the phase to 'recording'. A surface told only about
the first shows the wrong one for the whole turn.
turnSpeech replaced `@offgrid/core/stores` wholesale, leaving `useAppStore` undefined for the voice
session; it now spreads the real module and overrides only the store it fakes.
- The admitted record carries `content_hash`. That is what the bytes ARE, which the record id cannot say: a re-mint gives the same picture a new id, so a peer keyed only on the id cannot recognise an echo of a file it already holds. Matched as a sha256 rather than the fixture's literal digest. - The files library shows ONE row for four sent files, not four. Generated media lives in the gallery and the chat that made it; a message attachment lives in its bubble. Listing them here too is the bug the sharing catalogue removed - it "showed hundreds of apparent files nobody shared". The assertion was still describing the behaviour from before that rule. - Dropped a `requestStateSync` start option that nothing has ever called. Both suites claimed to be wired as the app wires it while passing an option the app does not pass.
Removes the ambientShareService line-cap and desktop durable-control entries, both now done. Adds the three voice flows that need a phone: every symptom in that area's history (B12, B26, B28) was device-only, and these fixes are verified against faked native leaves.
Points at the four pro commits: controls read through to the log on demand (the startup replay is gone), the shared-file coordinator adapter split out of a 552-line service, one place that writes a delivery row, and the local twins of the mesh rules replaced by @offgrid/sync.
Two fixes to the same gate. speech is a file: dependency like sync and rag, imported by six src files, and it was never built here - so every run failed on "Cannot find module '@offgrid/speech'" (1966 errors in the run before this) long before reaching anything real. And pro is now checked out explicitly on the branch that matches this PR, falling back to main, instead of `git submodule update --init`. The submodule pointer is deliberately a pin - it records which pro revision a core commit was verified against, which is exactly why desktop just gained one - but a pin is the wrong thing for CI to TEST: a coordinated core+pro change ran this branch's core against whatever commit the pointer held, so the two halves of one change were never seen together. Same shape as the shared checkout below it, and as OGAD already does. Seen live minutes ago: mobile-pro CI checked out this app branch mid-flight and failed on a `recordingController.report` that this branch had already removed.
The only red test in an otherwise green mobile CI run (8447 of 8456 passing). It passes locally every time - three consecutive runs to check - and failed on a GitHub runner where this single test took 9.6s against a 4s waitFor. The test-level budget is already 60s, so the four waits go to 15s. No assertion changes: it still proves the sheet is gone after Share on X and does not re-nag on the 10th generation. This only surfaced now because CI never reached jest before - the typecheck gate died first on @offgrid/speech never being built.
…null Three tests for a file arriving from another device, an area nothing covered: the file is imported and becomes a file this phone actually holds, a withdrawal on the far device deletes the bytes as well as the row, and bytes described differently from the record are discarded rather than imported under a record that does not describe them. The first of them is what caught the contentHash loss in pro - it asked the admitted record for a sha256 and got undefined. It also fixes the two tests already in that block. They passed camelCase control fields, and the control parser rejects any unknown key outright, so `mimeType` made the whole control parse as null: nothing ran at all, and "no file yet" passed for the wrong reason. Both shapes are now built by the producers the app itself uses - createSharedFileStateFields for the record on the wire and createSharedFileTransferMetadata for the envelope staged beside the bytes - so a hand-written literal can no longer drift from the format the sender writes.
A control is a DESCRIPTION and usually carries no hash, so spreading one over the record erased the contentHash importFile had just computed from the real file. That is the field findDuplicateContent is keyed on: a record making no content claim cannot recognise the same bytes arriving under a re-minted id, so the file imports again.
A device that has lost its copy asks the devices that might still hold it, and answering is a consent question with a failure mode on each side. Too strict and the phone refuses to heal a file it has already sent - which happened, on the same mesh, while the Mac obliged, because the phone keeps an approval state and a transfer state where the Mac keeps one status and the two were read differently. Too loose and a peer helps itself to a file the user never approved by claiming to have lost it. Nothing covered the rule that decides this, so seven tests walk a delivery through every state it can be in and pin the verdict for each: never offered, still on the approval sheet, queued for a device that is away, mid-transfer, finished, failed, and the one granted without sending because those bytes were already there. Three more on what the sharing screen keeps back. A voice note goes to every paired device with no rule to set, so recording a few on a train used to fill Activity with Pending rows the catalogue had already ruled out - the list now shows the screenshot that needs a person and hides the note that does not. An approval raised before the mesh roster loads still names a device, because "Share this with ?" is not a question anyone can answer. And a question about a file that has since gone disappears from the sheet while the consent row stays, so the question comes back if the file does. The last covers the sender side of duplicate suppression: two files that make no claim about their content are not thereby the same file. Reading "no hash" as a match would silently drop the second screenshot, and the user would watch one photo reach their Mac and never see the next.
Identities get re-minted - a device repairs its install, a record is re-admitted - and the file itself does not change when that happens. So an arriving record has to be recognised by what its bytes ARE rather than by the id it wears, or one download becomes a pile of identical rows that this phone then offers on to every device it is paired with. That lookup was written and never exercised: the store was empty in every test that reached it, so the guard ran against nothing. Four tests through the real service. The same bytes arriving under a new id leave one row standing and the arriving copy is dropped rather than parked in the staging area. A file the platform cannot hash is still imported, because the digest is how duplicates are spotted later and not what makes a file usable now. That record is stamped from the bytes on disk at the next launch, which is what repairs a library admitted before the field existed - and the test then shows the repaired record recognising an echo of itself, which is the whole reason the backfill exists. And a record whose bytes have gone is left unstamped: a content claim this device cannot serve would make the phone refuse the very copy a peer could have used to heal it.
`voiceTurnMode: 'handsfree'` is a global setting and makes the voice session start in `listen`, and nothing re-initialises that session when the interface mode changes. So a hands-free setting armed the microphone in a TEXT or IMAGE chat too: the composer came up as "Slide to cancel" over a keyboard-less text chat, always listening. Reproduced on a OnePlus and confirmed fixed there. The gate goes in `micShouldBeOpen()` - the one choke point every mic-open path funnels through (the session driver on mount, its transition subscriber, `startRecording`, the silence endpoint) and the one place evaluated fresh on every call. It now also requires `interfaceMode === 'audio'`. Gating the session's INITIAL state instead would cache wrong: the session is built once, so one built in text mode would stay stopped even after switching to voice.
`voiceTurnMode: 'handsfree'` is a global setting that leaves the session in `listen`, and nothing re-initialises the session when the interface mode changes. So the hands-free auto-arm opened the mic on mount in a TEXT or IMAGE chat too - the composer came up as "Slide to cancel" over a keyboard-less chat, always listening. Found and fixed on a OnePlus. The gate goes on the DRIVER's `startTurn` in Voice.ts - the one hands-free auto-arm path - so it now requires `interfaceMode === 'audio'`. Deliberately NOT on `micShouldBeOpen` or `startRecording`: tap-to-dictate in a text chat is a real feature and goes through recordingController, not this driver. An earlier broader cut gated `micShouldBeOpen` and killed chat-mode dictation; the chat-STT suites caught it. Also resets the voiceSession and recordingController singletons in ChatInput.test.tsx's beforeEach: they are module singletons, so one test's mid-listen state leaked into the next and a later tap was refused - green in isolation, red in the suite.
…hat happened to read them Device-observed: deactivate the DeepWiki MCP server and the mounted chat's quick-settings badge kept saying 3 while the Pro tools screen said none; a fresh chat said 0. The count was computed at render time from `getState()` reads that nothing subscribed to - the same class of staleness the `useIsProActive()` comment above it already records. `useExtensionToolCount` reads the sum through `useSyncExternalStore`, subscribed to every extension that can change (the contract gains an optional `subscribe`). Core stays ignorant of pro stores - the extension registry is the seam, as before. Verified on the phone in both directions without leaving the chat: 3 -> deactivate -> no badge -> reactivate -> 3.
The gate itself is unchanged; the earlier wording pushed useVoiceInput past max-lines-per-function.
The tool-count hook read `getToolExtensions()` through `useSyncExternalStore`, but the registry itself notified nobody on register - so its snapshot source could change with no subscribed notification. That violates the hook's contract, and the reason the badge still worked was two accidents holding hands: `useIsProActive()` re-renders the chat when Pro registers its screens, and the subscribe callback was inline, so that re-render tore the subscription down and re-wired the newly-registered MCP extension. Any other consumer - a tab badge, a settings row - mounted before Pro activates would have subscribed to zero extensions and shown 0 forever, which is the same staleness this hook exists to remove. `registerToolExtension` now notifies listeners and `subscribeToolExtensions` exposes them, mirroring screenRegistry, whose comment records this exact late-registration case. The hook's subscribe moves to module level (stable, so React wires it once per mount instead of on every streaming token) and re-wires each extension when the registry changes.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Scope\n\nMobile changes for background clipboard capture, safe filesystem reads, llama.rn 0.13, chat and attachment identity, model vision repair, loader consolidation, and the dated manual and repair plans. The PR also points at the Mobile Pro sync-feedback branch.\n\n## Status\n\nDraft. Published to show the complete current delta against main. This branch is not merge-ready. The filesystem fake migration is incomplete and the current full test gate is red.\n\n## Validation\n\n- TypeScript and lint passed before the latest test migration\n- Current Jest failures are recorded in the repair plan\n- Pre-push hooks were skipped for this publication at the owner's direction
Summary by CodeRabbit
New Features
Bug Fixes