Skip to content

fix(harmonyos): make the conversation screen usable during a run - #2214

Open
wgqqqqq wants to merge 3 commits into
GCWing:mainfrom
wgqqqqq:feat/harmonyos-conversation-fixes
Open

fix(harmonyos): make the conversation screen usable during a run#2214
wgqqqqq wants to merge 3 commits into
GCWing:mainfrom
wgqqqqq:feat/harmonyos-conversation-fixes

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Six things the HarmonyOS session screen got wrong on device, reported together after a round of real-device use, plus the relay-side cause of one of them.

What was broken

The timeline never followed streaming output. scrollEdge(Edge.Bottom) only existed inside the floating button's onClick, and the list was explicitly stackFromEnd(false) — so a bubble growing every 40ms grew below the viewport and the agent's reply was written where nobody could see it.

The composer locked to Stop for the whole run. isStopping folded dictation and a running turn into one flag, but they sit on opposite sides of the draft: dictation outranks it because the draft is still being spoken, a running turn only outranks it where the draft has nowhere to go. Remote sessions can hand a message over mid-run (the server already queues it — remote_server.rs), so keeping the button on Stop there trapped what was typed.

Stop did not stop. Between sending a message and the server's turn_id arriving, remoteActiveTurnId() sliced the local active-pending-<id> into pending-<id> and sent that as a turn_id. The server read it as StaleRequestedTurn and kept running. The error was also invisible — it went to statusText, which only renders while disconnected — so the user saw nothing happen, twice.

AskUserQuestion options were cut off. OptionRow was pinned to 36px with no maxLines on the label and no layoutWeight on the description, so a long option lost its tail with nothing to indicate it had one.

Picked images rendered outside the input box instead of inside the composer card.

The plus button opened an attachment panel offering quick prompts and a single image entry. RemoteCommand::SendMessage carries no file channel, so a menu there was a menu of one.

Attaching a photo failed with a bare 413. is_valid_encrypted_payload caps the ciphertext at 48 MB, but /api/devices/:id/rpc inherited Axum's 2 MB DefaultBodyLimit, which rejects the body before the handler — including its auth check — ever runs.

What changed

  • ChatTimeline flips to stackFromEnd(true) for the growth case, adds a timelineRevision monitor for whole-message inserts, and gates both on a stickToBottom flag driven by isAtEnd() so scrolling back through history is not fought by the follow.
  • ChatComposerPolicy.primaryAction() splits isStopping into isVoiceListening and isTurnRunning, with a draft outranking a running turn on surfaces that support mid-run send. The policy itself is centralized here (first commit) so both the button glyph and its click branch read the same decision.
  • remoteActiveTurnId() returns '' during the pending window, which the server resolves as CancelCurrent; the stop path reports failures through the existing toast channel instead of the disconnected-only status bar.
  • OptionRow grows with its content; label and description stack rather than compete for one line.
  • Thumbnails move into the composer card, left-aligned (Scroll centres content narrower than its viewport, which parked a lone thumbnail mid-composer). Plus opens the album directly, matching local chat; the attachment panel and its orphaned strings are removed. Image encoding gets a size budget, since the transport doubles what it is handed — base64 into the command JSON, then base64 again after AES-GCM.
  • The RPC route gets a DefaultBodyLimit derived from MAX_ENCRYPTED_PAYLOAD_BYTES, so the ceiling and the validator cannot drift apart again.

Scope note: mid-run send is remote-only. Local general chat has no queue behind it and keeps its current behaviour.

Verification

  • assembleHap — BUILD SUCCESSFUL
  • HarmonyOS LocalTest — 239 passed, 0 failed
  • cargo test -p bitfun-relay-service --lib routes::devices — 6 passed, including two new cases pinning both ends of the body limit (a 3 MB body reaches the handler; one past the ceiling is still refused)

The relay fix needs a deploy before phones stop seeing 413.

Six things the session screen got wrong on device, all reported together.
They land in one commit because they overlap in the same few files.

The timeline never followed streaming output. `scrollEdge(Edge.Bottom)` only
existed inside the floating button's `onClick`, and the list was explicitly
`stackFromEnd(false)`, so a bubble growing every 40ms grew below the viewport.
Flipping `stackFromEnd` handles the growth case; a `timelineRevision` monitor
covers whole-message inserts, and a `stickToBottom` flag reads `isAtEnd()` so
scrolling back through history is not fought by the follow.

The composer locked to Stop for the whole run. `isStopping` folded dictation
and a running turn into one flag, but they sit on opposite sides of the draft:
dictation outranks it because the draft is still being spoken, a running turn
only outranks it where the draft has nowhere to go. Split into
`isVoiceListening` and `isTurnRunning`; remote sessions can hand a message over
mid-run, so a draft now wins there and the relay queues it.

Stop did not stop. Between sending a message and the server's turn_id arriving,
`remoteActiveTurnId()` sliced the local `active-pending-<id>` into
`pending-<id>` and sent it as a turn_id. The server read that as
`StaleRequestedTurn` and kept running. It now returns '' in that window, which
the server resolves as CancelCurrent. The failure was also invisible — it went
to `statusText`, which only renders while disconnected — so the stop path now
reports through the existing toast channel.

AskUserQuestion options were cut off. `OptionRow` was pinned to 36px with no
`maxLines` on the label and no `layoutWeight` on the description, so a long
option lost its tail with nothing to indicate it. The row now grows with its
content and label/description stack instead of competing for one line.

Picked images rendered outside the input. They now sit in a strip inside the
composer card, left-aligned — `Scroll` centres content narrower than its
viewport, which parked a lone thumbnail mid-composer.

The plus button opened an attachment panel offering quick prompts and a single
image entry. A message can only carry images (`RemoteCommand::SendMessage` has
no file channel), so a menu there was a menu of one; plus now opens the album
directly, matching local chat. The panel and its orphaned strings are gone.
Image encoding also got a size budget, since the transport doubles what it is
handed: base64 into the command JSON, then base64 again after AES-GCM.
`is_valid_encrypted_payload` caps the ciphertext at 48 MB, but the route
inherited Axum's 2 MB `DefaultBodyLimit`, which rejects the body before the
handler — including its auth check — ever runs. A phone attaching a photo got a
bare 413 from a route that claimed to accept 24x that.

The limit is derived from `MAX_ENCRYPTED_PAYLOAD_BYTES` so the two cannot drift
apart again, with slack for the JSON envelope and the base64 nonce. Two tests
pin both ends: a 3 MB body reaches the handler, and one past the route ceiling
is still refused.
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