Skip to content

refactor(chat): resolve a link card in the card, not in the transcript - #1485

Merged
bmc08gt merged 2 commits into
code/cashfrom
refactor/link-card-loading-state
Sep 18, 2026
Merged

bmc08gt merged 2 commits into
code/cashfrom
refactor/link-card-loading-state

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

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. 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 memoized Deferred is behind a Mutex — 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. The Deferred memo 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 revision StateFlow: 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 StateFlow rather 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.

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.
@bmc08gt bmc08gt self-assigned this Sep 18, 2026
@github-actions github-actions Bot added the type: refactor Code restructuring, no behavior change label Sep 18, 2026
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
bmc08gt force-pushed the refactor/link-card-loading-state branch from a46ae9a to fe65bc9 Compare September 18, 2026 13:11
@bmc08gt
bmc08gt merged commit b5daa0c into code/cash Sep 18, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the refactor/link-card-loading-state branch September 23, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant