Skip to content

Sync feedback: Mobile runtime, clipboard, and model fixes - #628

Open
alichherawalla wants to merge 229 commits into
mainfrom
release/sync-feedback
Open

Sync feedback: Mobile runtime, clipboard, and model fixes#628
alichherawalla wants to merge 229 commits into
mainfrom
release/sync-feedback

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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

    • Added Android accessibility support for capturing and syncing copied text when standard clipboard access is unavailable.
    • Added vision-model repair guidance and recovery flows.
    • Added pending attachment indicators, supporting context, and clearer tool-status displays in chat.
    • Added platform-specific image-generation defaults: 8 steps on Android and 50 on iOS.
    • Added reusable loading indicators and a selectable dropdown component.
  • Bug Fixes

    • Improved model validation, image handling, cancellation, clipboard timestamps, and model unloading.
    • Improved synchronization of generated media, documents, preferences, and device recovery.
    • Improved image-generation progress, cancellation, and result persistence.

…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.
@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