Repository navigation
refactor(chat): resolve a link card in the card, not in the transcript - #1485
Merged
Merged
Conversation
Opening a chat painted nothing until every link in the first paging window had been round-tripped. The paging transform in ChatViewModel awaited LinkCardResolver for each classified card before it could emit the page, and it queried every link in every mapped message whether or not the reader ever scrolled to it. The classifier now emits the card in a Loading state and LinkCardView runs the lookup itself, through a LinkCardResolution seam that chat-ui can see and LinkCardResolver implements. Loading draws the unresolved card with the fields that are about to change shimmering rather than a skeleton of its own: the branded ticket and the abbreviated mint are already real information, and a grey rectangle would show the reader less than the raw link did. A failure is the shimmer stopping and nothing else moving, so a card still never shows an error over a link that opens fine. The resolver keeps its landed answers a second time, in a map readable without the mutex, so peek() can answer during composition -- without that, every card scrolled back into view shimmers for a hop on a link the reader already watched resolve. The claim invalidation and the 15s re-ask for claimable vouchers stay with the screen and reach drawn cards through a revision StateFlow, replacing the whole-window re-map they used to trigger.
Three things the loading voucher got wrong, found by rendering the states rather than by reading them. The amount's slot was only present once the amount was. The column centres what it holds, so a failed lookup dropped the slot and slid the token name ~18dp down the card -- the one movement the card's own rule says a failure must not make. The slot is now held at displaySmall's line height in every state, as a minimum so a large font scale grows it instead of clipping the amount. The unresolved voucher drew a person's silhouette beside the word "Cash", because TokenIconWithName draws TokenIcon unconditionally and TokenIcon falls back to ic_placeholder_user for a null image. There is no honest icon for a token nobody has fetched, so nothing is drawn in its place; the row keeps the icon's height so the name does not move when one lands. The stub shimmered an empty slot over a control that already worked. The whole voucher is clickable in every state and opening the link is where claiming happens, so the claim pill is now drawn from the first frame. Claimable is the assumption until the lookup says otherwise, which trades an offer that is occasionally retracted for a claim that is never late; a voucher that comes back spent lands on a dimmed card with the label under the tear, so the withdrawal is unmistakable.
bmc08gt
force-pushed
the
refactor/link-card-loading-state
branch
from
September 18, 2026 13:11
a46ae9a to
fe65bc9
Compare
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.
Opening a chat painted nothing until every link in the first paging window had been round-tripped. The paging transform in
ChatViewModelawaitedLinkCardResolverfor each classified card before it could emit the page, and it queried every link in every mapped message whether or not the reader ever scrolled to it.The classifier now emits the card in a
Loadingstate andLinkCardViewruns the lookup itself, through aLinkCardResolutionseam thatchat-uican see andLinkCardResolverimplements. The transcript paints immediately; cards come up loading and fill in, and only a card someone is looking at costs a query.What loading looks like
Loading is the unresolved card with the fields that are about to change shimmering, not a skeleton of its own. The branded ticket, and the abbreviated mint on a token card, are already real information — a grey rectangle would show the reader less than the raw link text did while they waited.
On the cash card that means the amount and the stub slot. The type row stays steady, because it reads "Cash" from a string resource and merely refines to the token name on resolve; shimmering it would say "unknown" about something already correct. On the token card the shimmer goes behind the address rather than over it, so the one thing known about the mint stays readable and what pulses is the bill surface, which is the part that really is a placeholder.
A failure is the shimmer stopping and nothing else moving. That keeps the existing rule: a link card never shows an error, because the link underneath still opens.
Why the resolver keeps its answers twice
peek()runs during composition, and the memoizedDeferredis behind aMutex— reaching it means suspending, which means a frame of shimmer on a link the reader just watched resolve. So landed answers are also held in maps readable without the lock. TheDeferredmemo still does what it did: dedupe the several cards holding the same link into one query.Only successes are recorded. A failure is forgotten so the next card asks again, which is safe now that the ask is per-appearance rather than per-re-map.
The two things that stay behind
The claim invalidation (a claim settling on this device) and the 15s re-ask for cards still showing claimable both belong to the screen, not to a card. They reach drawn cards through a
revisionStateFlow: they drop the stale answers, bump it, and every card re-asks — with the memo answering instantly for each one whose answer did not move. That replaces the whole-window paging re-map these used to trigger to redraw one voucher.It is a
StateFlowrather than a plain flow so a card composed after a claim reads the current count at its first frame instead of seeing a spurious change and re-asking for an answer it had just peeked. The failure path deliberately does not bump, or fail → forget → bump → retry loops forever.Cross-platform
iOS is making the same change on
feat/link-card-loading-state. The two sides agree on the loading rendering, on peeking before suspending, on the per-key task, and on forgetting failures.