Skip to content

fix: harden Android remote chat reliability - #6

Open
JairEsk wants to merge 22 commits into
mainfrom
jair/fixbugs1.0
Open

JairEsk wants to merge 22 commits into
mainfrom
jair/fixbugs1.0

Conversation

@JairEsk

@JairEsk JairEsk commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • harden pairing, connection recovery, draft preservation, and mobile turn cancellation
  • stabilize transcript ordering, follow-tail behavior, and streamed part identity
  • synchronize desktop/mobile turn state, render reasoning deltas, and surface remote failures
  • add regression coverage and implementation notes for the Android v1 reliability work

Testing

  • git diff --check
  • ./gradlew testDebugUnitTest (blocked before project configuration by the local Gradle/JVM error: Unable to establish loopback connection)

The chat list drove autoscroll from a followTail flag recomputed as
`!listState.canScrollForward` whenever scrolling settled. Streamed
markdown grows after each measure pass, so the pass right after a scroll
to the bottom made the list scrollable again and cleared the flag by
itself, stranding the viewport above the newest message.

Two further problems came from the same design: scrollToItem takes the
ScrollableState mutex, so it raced user gestures and flings and could be
cancelled mid-animation, and the layout -> scroll -> layout loop only
terminated because snapshotFlow deduplicates.

Invert the ownership instead of patching the flag. With reverseLayout the
newest row is index 0 and is drawn against the bottom edge, and the list
anchors scrolling on the first visible item, so staying at the bottom
becomes a property of measurement rather than something code chases: a
growing row is the anchor, so it expands upwards on its own.

Only insertion still needs handling, since stable keys keep the anchor on
the previously newest row. requestScrollToItem does that without taking
the scroll mutex, so it cannot race gestures.

Keys are kept, unlike Jetchat, because ReasoningCard and code blocks hold
rememberSaveable state that would otherwise shuffle between rows.

Flatten the transcript into ChatRow up front so indices are meaningful,
and key the list state on a new sessionId so each session keeps its own
position -- the old reset compared title and project, which collides
between sessions sharing a name.
Replace the single-line PIN field with six per-digit cells, backed by one
BasicTextField holding the whole PIN rather than one field per cell.

That backing choice is what makes backspace erase the PIN digit by digit.
With six real fields an empty cell never receives the key event at all --
there is no text to delete, so the IME emits nothing -- and deletion
stalls on the first empty cell unless keys are forwarded between fields
by hand with onKeyEvent and FocusRequester. One field keeps the caret
after the last digit, so deleting right to left is the native behaviour.

The caret is also pinned past the last digit on every recomposition, so
tapping a cell cannot drop it mid-PIN and desynchronise typing from the
cell being looked at, and input is filtered in onValueChange instead of
relying on the keyboard type, which covers paste and autofill.

Cells divide the available width so they stay equal on any screen; fixed
widths overflowed the dialog, and an overflowing Row shrinks whichever
child runs out of space last, which left the sixth cell narrower. Height
stays fixed because aspectRatio grew the cells vertically once their
width was shared.

Also give the connect button room for its spinner and label: the default
content padding wrapped "Connecting..." onto a second line, which broke
the button's vertical centring. The spinner was drawn in colors.bg while
the button sits disabled on surface2, making it nearly invisible, so it
now uses the disabled content colour.
BasicTextField's TextFieldValue overload is fully controlled: its internal
buffer only follows the passed value on recomposition. The filter skipped
onValueChange when the digits were unchanged, which is exactly the case
that needs the resync -- so no recomposition happened and the buffer kept
characters the PIN had rejected.

Typing a 7th digit into a full PIN left 7 characters in the buffer while
6 were displayed, so the next backspace deleted the invisible one and the
visible digit needed a second press. Pasting letters into an empty field
left them buffered the same way.

Hold the TextFieldValue locally and assign it on every change, so the
field is corrected even when the PIN itself does not change.
buildChatRows appended the orphan tool pile before reversing, so whenever
it existed it became index 0 for good, and its key is a constant. That
defeated both halves of the pin: an incoming message landed at index 1
leaving the newest key unchanged, so the LaunchedEffect never fired and
the message stayed off screen, and requestScrollToItem(0) on submit
scrolled to the tool pile instead of the sent message.

Orphans are reachable in production -- SnapshotReceived takes event.tools
as-is without requiring the tools to appear in parts, so any desktop
snapshot carrying a non-empty tools list produces them.

They have no chronological anchor, so prepend them instead: they render at
the oldest end of the transcript and the newest row is always a real
message. Fixing the ordering rather than excluding them from the newest-key
computation also corrects the submit scroll, which the narrower fix would
have left pointing at the pile.
TurnChanged minted a fresh UUID for the in-flight text part on every
event, not just when the turn closed. Each streaming chunk therefore
changed the row key, which the list reads as a removal plus an insertion:
the row was rebuilt, its layout state discarded, and LaunchedEffect on
the newest key re-fired spuriously.

The old autoscroll survived this by re-pinning every frame by brute
force. Anchoring the viewport on keys makes their stability a
correctness requirement, so fix it here.

Reuse the containing message's id, which is already stable across the
turn, and shape the part id like the snapshot parser's "\$id-text-\$p" so
the key also survives the streamed turn being replaced by the
server-sent version when the turn closes.
isError was wired to errorMessage != null, but connectionError is a
single field shared with QR scanning, so "Invalid QR code: no Roxy
connection token found." and "QR Scanner error: ..." painted the PIN
cells red despite saying nothing about the PIN. Nothing cleared it as
the user typed either, since the error lives in the view model and is
only reset by another connection attempt.

Drop the cell marking once the PIN is edited. The banner still shows the
message, so no information is lost, but the cells stop attributing the
failure to the digits.

Also let the active cell outrank the error state in PinCell. The error
arm came first, so an errored field lost its active-cell highlight
entirely and the user had no visible entry point while retrying.
RoxyApp switches destinations with a when, so ChatFullScreen leaves the
composition on the way back to Main and its list state is recreated
anyway. openSession is only reachable from the session list, so there is
no chat-to-chat transition: the sessionId could only change while
mounted during disconnect, which clears the messages regardless.

The field and its four assignments existed purely to key the list state
on something navigation already guarantees, and the comment claimed a
behaviour that was never the reason it worked. Remove it rather than
document it as defensive -- if in-place session switching is added, the
list state can be keyed then, at which point it will actually do
something.
rows describes renderable rows, not whether the session has a transcript.
An assistant message with no parts and blank text legitimately produces
zero rows, but the session is not empty and should not show "No messages
yet. Send a prompt to get started." The previous condition regressed the
old messages.isEmpty() && toolCalls.isEmpty() check by conflating those
two meanings.

Restore the semantic check against the source UI state and leave the list
branch responsible only for rendering whatever rows exist.
buildChatRows is rebuilt on every streaming message update, so it does an
O(n) transcript walk per chunk. Keep that transformation in the UI layer:
rows are presentation state for LazyColumn, and moving the same walk into
the ViewModel would couple business state to composable row shapes without
reducing the work.

Make the trade-off explicit, and remove the avoidable extra pass over
rows used to recover rendered tool ids. Record those ids while walking
message parts instead, and only allocate the set when the snapshot
actually has tools to compare against.
Move PaddingValues back into the capitalized layout import group so the
file keeps its existing import ordering convention.
A reversed chat list should keep following the newest row when the
viewport is effectively resting on it, not only when its scroll offset is
exactly zero. A one-pixel offset after a fling or nested scroll should
not opt the user out of autoscroll.

Use a viewport-relative threshold for the follow-tail check, persist the
LazyListState with its saver, and show an explicit Latest affordance when
new rows arrive while the user is reading history. Sending remains the
only forced jump to row 0 because a user-submitted prompt is explicit
navigation back to the live edge.

Expose buildChatRows internally and cover row ordering/key behaviour with
unit tests, including orphan tools at the oldest end.
The turn frame parser collapsed text and reasoning parts into one
inFlightText blob. That made ReasoningCard disappear during streaming and
then reappear only after the final snapshot, and it made the stable row
key work depend on a synthetic single text part that did not match the
snapshot shape.

Emit the live parts from RemoteWorkspaceClient alongside the legacy
inFlightText summary, and let the ViewModel rebuild their ids from the
stable containing message id plus the source part index. That keeps text
and reasoning rows stable across chunks and aligned with the snapshot ids
("-text-" / "-reasoning-").

Add regression coverage for parser output and ViewModel key stability.
Keep the pairing PIN length in one shared constant instead of scattering
literal 6 checks across the dialog, parser, client validation, and
PinInput default.

Make the token field's Next action focus the PIN, and focus the PIN
immediately when a QR scan prefills the token but still needs manual PIN
entry. PinInput now keeps the editable text field's own semantics and
marks it as a password instead of overriding the same node with a custom
contentDescription that conflicted with EditableText in TalkBack.

Add an instrumented Compose regression test for rejected input: pasting
letters plus a seventh digit should not leave an invisible character in
the IME buffer that requires an extra backspace.
# Conflicts:
#	app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt
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