Sync feedback: Mobile runtime, clipboard, and model fixes - #628
Open
alichherawalla wants to merge 107 commits into
Open
Sync feedback: Mobile runtime, clipboard, and model fixes#628alichherawalla wants to merge 107 commits into
alichherawalla wants to merge 107 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.
… Tool Calls In-chat Text Settings and Settings > Model Settings each carried their own copy of the same controls, so the two surfaces could disagree about what the model was actually told. Both now read and write `appStore` through a shared hook, which is also what the settings sync payload carries - one owner, three readers. Maximum Tool Calls joins them as a real user setting: default 25, range 1-100, honoured by both the JavaScript and LiteRT tool loops rather than a constant compiled into each. The `TextGenerationAdvanced` panels are gone; they were the second copy.
The engine sized its own context and output length from device memory, while Model Settings offered the user controls for the same numbers - two answers to one question, and the user's was the one being ignored. The auto-context probe and its device-derived max are gone; `initConfiguredContext` uses the configured length, and compaction targets the user's model-aware maxTokens. `llmDeviceLimits` keeps only what is genuinely the device's to answer.
…he row Two defects in a tool-calling run, both visible on all four surfaces: Reasoning accumulated. Every tool boundary re-rendered the whole conversation's thinking into the current block, so by the sixth call the active bubble restated everything the model had already thought. A completed segment is now stored at its boundary and the next active segment starts empty. Completed tool rows rendered BELOW the active reasoning, so finished work sank under a live "Thinking..." row. Synced tool rows now render before an active streaming thinking bubble, which keeps the active thinking row last. The native boundary harness gains scripted llama completions so a tool loop can be driven through real services in a test.
The prompt returned on every qualifying session, so declining it once meant declining it again and again. "Never show" records the choice and closes it.
Projects, project detail, knowledge base, tools and the sheets carried no
stable handles, so a device journey had to find them by visible text - which
breaks on a copy change and cannot tell a toggle's state apart from its name.
Adds testIDs and state-bearing accessibility labels ("Web Search, ON") to
exactly the controls the journey touches. No behaviour changes.
…odfile.lock The barrel re-export made a picker sheet depend on the whole services index.
A staged runner for the four-surface journey: prepare a project chat with Thinking and the standard + DeepWiki tools on, send one guided prompt from the phone, and verify from the model's own wire log that every named tool was called and none more than twice. Each step performs one visible action, records before/after, and exits, so a UI or control-channel failure cannot resubmit the prompt - the send is guarded by durable state. Also here: - `appium-client` for text entry the UiAutomator dump cannot do, and a `desktop-target` rule that picks the real renderer rather than a clipboard or notification popup. - `open-project-chat`, so the three observers can be put on the same chat. - `prove-peer-port-change`, which reproduces a peer restarting onto a new ephemeral port and asserts the desktop follows it unattended. That is the regression harness for the stale-address bug fixed in @offgrid/sync. - Project fixtures for the knowledge base the journey queries. - `/tmp/` ignored: it holds PNG renders derived from those fixtures.
The toggle's label gained the state it carries ("Use launch-brief.txt, ON"), so
an exact-match query for the name alone no longer found it. The assertion now
names the state too, which proves more than it did before: that the control
announces both which document it governs and whether that document is in use.
The pre-push gate failed on `appStore.ts` (596) and `generationServiceHelpers.ts` (636). Decomposed along the seams that were already there rather than raising the cap. appStore keeps the store. Two concerns leave it, both pure and testable without one: - `appStoreModelRecovery` - which recovered models this app will trust. Recovery reads whatever survives on disk, and a file can outlive the metadata that described it. - `appStoreMigrations` - the one-time repairs to state written by older builds. `DEFAULT_SETTINGS` is passed in rather than imported, so the store stays the single owner of its defaults and the migrations stay a pure transform. That also leaves no runtime import back into the store. generationServiceHelpers keeps the LOCAL engines (llama.rn, LiteRT). `generationRemoteHelpers` takes the remote server paths: a different question - what a server on the network will run, not what this device can - sharing only preparation, meta and tool-loop wiring, which are now exported. The remote entry points are re-exported so every caller keeps one import site. No behaviour change.
The dependency gate rejected both splits: `appStore -> appStoreMigrations -> appStore`, and a real runtime cycle through the convenience re-export of the remote entry points. The migrations now name no store type at all. `migratePersistedState` is generic in the state it is handed and declares only the three settings fields these repairs actually read, so the store's type stays a superset and nothing here imports the store back. The re-export is gone. `generationService` and the branch tests import the remote paths from `generationRemoteHelpers` directly - one module, one import site, which is what the re-export was papering over.
…ale limit Extracting the recovery predicates out of appStore made a THIRD copy of them: `modelSelectorFilters` already held `isUnknownLike`, `isSuspiciousRecoveredTextModel` and `isSuspiciousRecoveredImageModel`, byte for byte, and the store had been carrying its own since before that module existed. The store's copies are gone; `isWhisperTextModel` and `isExcludedTextModel` join the module that already answered this question. `TOOL_STEP_LIMIT_NOTICE` is removed. It baked the notice at the old hardcoded 25 while both live callers build it from the maximum actually in force, so it was a second source for a number that is now the user's to set - the kind that starts telling people "the 25-step limit" after they choose 40. Nothing used it. The remaining exports narrow to what is actually imported: the migrations expose only their entry point, and the settings formatters stay local.
The header's model selector and project row shared one line with the shrink
rule the wrong way round: the project row was `flexShrink: 0` and the model
selector `flexShrink: 1`, so the project refused to give ground and the model
control collapsed to nothing. With a long enough project name the user could
neither see which model was loaded nor tap to change it.
Reversed. The model label is fixed text ("Models") plus two small icons, so its
width is bounded and it can safely refuse to shrink; a project name is
arbitrarily long and is the thing that should truncate. `minWidth: 0` lets its
Text actually ellipsize rather than overflow.
Verified on device with "Off Grid AI projecttoolmesh20260815174420": the header
now reads "Models > . [] Off Grid A..." with the selector intact.
A turn's calls arrived as a dense block: four or five rows crammed into a single container at 2px apart, centred and inset 16px INSIDE the reply column, while each finished result stood alone, left-aligned and 16px from its neighbour. Two rhythms and two left edges in one transcript, which read as tool calls nested inside one another - and mid-stream the count appeared to double, then halve. Three renderers drew tool activity; now one component draws every row, whatever produced it. What a row IS - a call the model asked for, or the result that came back - is a prop, so they stay distinguishable without forking the layout. The "Using X" row sits closer to the result that answers it than to the next call, because they are one event.
…nded `isModelLoading` lived in a component's `useState`. A fact known only to a component is a fact sync cannot see: the phone showed "Loading Qwen3.5 2B" for tens of seconds while every paired device sat on "Preparing reply...", because the live-stream service subscribes to this store and there was nothing here to read. The setters keep their signatures, so no caller changed. `lastReplyEnd` is the other half. Only this store knows whether a finished reply actually stored anything, and that is the difference between a peer retiring its preview and waiting for a record that is never coming. `chatStoreReplyFinalization` takes the decision out as pure logic - what a finished reply leaves behind - which is testable without a store and keeps this file inside the line cap it had outgrown.
This screen enumerated the status phases by hand in three separate places in one function, so a new phase meant finding each list - and missing one drew an empty bubble where a status line belonged. It asks the shared rule now. A status phase renders BESIDE whatever arrived, so it only becomes the whole row when nothing else has. Getting that wrong replaced a remote answer's text with a status label, which two existing tests caught.
The picker's padding sat on the ScrollView's `style`, which pads the VIEWPORT rather than the scrollable content - so the last project sat flush against the bottom of the phone with its subtitle clipped and nothing below it. Inner spacing belongs to the content container, with room past the last row for the home indicator.
The observer half of a fix that only landed on desktop. Every ordinary tool call arrives as its own synced message and is drawn inline, in order, with the duration it took - and the remote preview carried the same list again, so each call rendered a SECOND time in a block under the answer. When the preview retired, that block vanished, which reads as tool calls being eaten mid-run. Ownership rather than deletion: the preview keeps only tools nothing durable will ever replace. Image generation is exactly that - `generate_image` is a lifecycle marker, not a tool result, so its row exists only here. Removing the rows outright would have taken the image status with them, which is what the image lifecycle test caught.
Both sliders stopped at the model's trained limit independently, so output could be set above the context it writes into - a setting the engine can never honour, and one that squeezes the prompt out of its own window. The context is the ceiling, asked in three places by one rule: the slider's own maximum, the value it displays (a number stored by an older build must not render past the end of its slider), and the write itself, so nothing else can store an illegal value either. Lowering the context brings output down with it, rather than leaving a stored value silently above its own limit. The tests that asserted both sliders reach 262K encoded the old rule; they now state the new one. The parity test raises the context first, because max tokens is capped BY it and the write it makes would otherwise be clamped away from what it means.
Nothing outside this hook asks the question, and an export nothing imports is a public surface that has to be kept working for no reader.
…h run
The image journey started in whatever state the app happened to be in, so a slow
run and a fast one were not the same test and neither could be compared with the
last. This sets the four things that decide what the run actually exercises,
through the controls a person uses: steps at maximum, size 512, GPU on, and
prompt enhancement chosen per run - it adds a whole model pass before the image,
so the two cases are worth running separately.
Values are TYPED into each slider's value field rather than dragged. A drag lands
wherever the gesture ends, which is how a "maximum steps" run quietly becomes a
47-step one.
The GPU switch gains the same state-bearing label the other toggles carry
("GPU Acceleration, ON"), because a journey cannot assert a switch it can only
see the name of.
Its transcription sibling already had one. Without it a journey has to find the section by its visible text, which breaks on a copy change and cannot be relied on to open the right accordion.
A switch's truth is `checked`, not its label. `describeElement` reports geometry and identity, so a toggle asked only what it is CALLED cannot be told on from off - which is how a GPU switch already in the right state read as having no state at all.
Three things this had to learn on a real device. `uiautomator dump` serialises the WHOLE hierarchy in one shot and is KILLED on a long transcript - the exact chat an image journey runs in - so every lookup missed and the failure read as "the app has no home screen". Appium queries element by element and survives it. A long settings sheet does not render its off-screen rows, so a control below the fold is absent from the hierarchy rather than merely invisible. It scrolls to each one first, or "not found" reads as "this build has no GPU switch". A typed value only becomes readable again once the field submits, so each setting is committed and then READ BACK off the control. A field that silently refuses a number now fails here, rather than three minutes later as a run that quietly used the wrong settings. Verified on device: steps=50 size=512 GPU=ON enhancement=OFF, and the mesh journey then passed live, final image and Gallery on iOS, macOS and Windows.
The existing image journey generates from words alone. This one starts from a picture: the newest photo on the device is attached to the message, so the reply has to LOOK at something before it can make anything. The photo goes on the way a person puts it there - composer plus, the app's image-source sheet, then Android's system picker. There is no shortcut to take: the app has no ACTION_SEND filter and no deep link that carries an image. The picker is a separate application with its own view tree and none of our testIDs, so it is named by what Android exposes and the newest item is chosen by grid position rather than by guessing a filename. Mobile has no image-to-image path - no init image, strength or denoise anywhere in the app - so "change it" is honestly a two-step: vision reads the photo, and image generation draws from what it read. Asserting a true edit would be asserting a feature that does not exist. Two things this had to learn on a real device, both of which cost a run: Appium and `adb shell uiautomator dump` cannot both own UiAutomator. The device runs one instance, so an open Appium session makes every adb dump fail - and it reads as a wedged phone rather than a driver collision. The session is now held only around the steps that need it. A peer finds the turn by the chat-list PREVIEW, which truncates. A marker at the END of a long prompt never reaches the peers, and three observers time out on a conversation that synced perfectly well. The marker leads now. Verified: 4/4 surfaces - live phases, final decoded image, and Gallery.
`--primary ios` was accepted and then ignored: the flag only reached labelling, while the producer was hardcoded to Android in three separate places, so an iOS-primary run silently drove the Android phone. Three things now follow the flag. The preparation connects to whichever device is primary - it already spoke only the surface vocabulary, and both phones are the same React Native app, so the controls carry the same handles on each. The send picks its driver: Appium is the ANDROID driver and iOS is driven over WebDriverAgent, so the path cannot be shared, but the check that matters is identical - nothing counts as sent until the device shows it as a user message. Verification is the honest exception. The wire log is the strongest evidence there is, the model's OWN record of what it called, but it is read off the device with adb and so exists for Android alone. An iOS run is verified from the transcript instead and SAYS so in its result, rather than quietly claiming the same proof for weaker evidence. Android re-verified end to end after the change: Thinking ON, three standard tools ON, DeepWiki Active 3/3.
The image journeys always end in a picture, so the thing they can never prove is that an answer DERIVED FROM LOOKING reaches the peers as text. vision-answer-sync asks a question about an attached photo and asserts a settled reply - not a phase, and not an empty bubble. The four moves every producing journey needs move into android-producer, each carrying the reason it is shaped that way: Appium and adb's uiautomator dump cannot both own UiAutomator, a relaunch has to come BEFORE walking back or the presses land on the launcher, and a journey starts from a fresh chat because a long transcript is unreadable to the dump. --cold unloads the model so the loading phase is exercised deliberately rather than depending on a warm device.
Two defects with one cause: the row did not belong to the message above it. It was the only tool row still using the old centred systemInfoContainer after every other row moved to the shared toolRow rhythm, so it no longer took the column's width and its label truncated to 'Tools sent in requ...' with the count lost and empty space beside it. And a tool row stretched the full screen while the bubble stops at 85%, putting the chevron past the bubble's right edge - which reads as the row belonging to the screen rather than to that message. That 85% was written out in four places, so a row and its bubble could drift apart silently; it is now one MESSAGE_MAX_WIDTH.
|
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