Skip to content

feat(mobile): staged rollback with cross-device session synchronization - #3372

Merged
wgqqqqq merged 9 commits into
GCWing:mainfrom
wgqqqqq:wgq/mobile-rollback-cross-device-sync
Oct 13, 2026
Merged

wgqqqqq merged 9 commits into
GCWing:mainfrom
wgqqqqq:wgq/mobile-rollback-cross-device-sync

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the staged mobile session rollback from #3313 and fixes its host wiring, session ownership, and desktop history synchronization. A rollback initiated on either controller now removes the retired messages from the other controller's visible history.

This is a complete alternative to #3313 against current main. Its five original commits by @YodonTan are retained unchanged; the three follow-up commits isolate mobile state, register the remote rollback provider, and repair FlowChat invalidation.

Type and Areas

Type: feature and bug fixes.

Areas: HarmonyOS mobile, shared Core remote-session assembly, desktop FlowChat, focused regression tests.

Motivation / Impact

  • Original feature: short-message selection menus, truncated-message menus, and full-text menus expose rollback. The staged preview fades the affected suffix and supports cancel, confirm, and rollback followed by an edited resend. Existing copy/full-text behavior remains available.
  • Mobile ownership: switching sessions clears the old staging target, full-text panel, outcome, and notice without restoring A's draft into B. A session generation guards late rollback replies, send acknowledgments, and voice-stop completion, including A → B → A navigation. Resizing the same session preserves staging.
  • Host execution: the remote-session runtime receives the existing lifecycle rollback provider. Previously the mobile request failed with agent session revert port is not registered.
  • Desktop synchronization: history edits without a settled-turn identity reload the authoritative session, including an empty result and legacy payloads. Explicit settled-turn fences repair that turn without replacing a newer live turn. Previously a phone rollback could succeed on the host while FlowChat retained its deleted suffix.

No wire or persisted schema is changed. Remote-workspace rollback retains its explicit unsupported behavior.

Verification

Rerun on the combined branch after merging current main:

  • cargo test --locked -p openbitfun-core --no-default-features --features remote-connect --lib remote_session_host_registers_targeted_rollback_port — passed.
  • From src/apps/mobile/harmonyos: node --test tools/tests/rollback-wire.test.cjs tools/tests/session-rollback-policy.test.cjs tools/tests/session-rollback-presentation.test.cjs tools/tests/rollback-preview.test.cjs tools/tests/user-message-menu.test.cjs tools/tests/rollback-session-isolation.test.cjs — 73 passed.
  • pnpm --dir src/web-ui run test:run src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts src/app/services/sessionSceneLifecycle.test.ts src/flow_chat/services/sessionActivation.test.ts src/flow_chat/services/storeSync.test.ts src/app/stores/sceneStore.test.ts src/flow_chat/store/FlowChatStore.test.ts — 289 passed.
  • pnpm run check:web, pnpm run harmony:architecture, pnpm run mobile:ui:check, and pnpm run i18n:audit — passed.
  • HarmonyOS ArkTS compilation and unsigned HAP assembly — passed using source scripts/ohos-env.sh and "$HVIGORW" --mode module -p product=default -p module=entry@default assembleHap --no-daemon, with local signing disabled for this compile check.

Live remote-control checks on the tested signed HarmonyOS fix build and Mac development host, using an independent session and unique message/turn identities:

Case Result
Phone native rollback button → desktop 4 turns → 1; desktop updated automatically, observed within about 2 seconds.
Desktop production SessionRollbackService → phone 3 turns → 1; the open phone view updated automatically, observed within about 1.4 seconds.
Resend to the previous turn count Only the replacement suffix appeared; old user and assistant markers stayed absent.
Phone cold start / desktop reload and reopen Only authoritative history returned.
Phone rollback to empty history, then cold reopen Both controllers and the host turn catalog remained at 0 turns.

The desktop direction exercises the production service used by the rollback button; desktop pointer/menu confirmation is not part of that live synchronization evidence. These live runs preceded the latest-main merge; regression checks and the HAP compile were rerun on the combined tree. Original session and unsent draft were restored after testing.

The isolated native fixture was also exercised at compact/wide widths, light/dark themes, and live resize/fold transitions. Its Switch session and Resize controls are verification controls, not normal product UI. Deferred-response regressions use the production controller with mocked ports.

Reviewer Notes

  • pnpm run mobile:architecture still fails on upstream's existing Kotlin checks: one ClientBuildContractTest.kt reference to HarmonyOS and constructor-default findings in AccountUiState.kt, RemoteSidebarPresentation.kt, and ConversationModels.kt. The checker and every reported source are byte-identical to main at f7ab2591f; this PR does not change those contracts or loosen their baselines.
  • Exercised remote scenario: phone control of a Mac host. SSH workspaces, Peer Device Mode, and Detached Dispatch were not exercised. Physical device fold transitions and cross-version live connections were not tested; legacy history-event compatibility is covered by regressions.
  • Local signing material, generated packages, screenshots, and unrelated local work are excluded from the commits.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, and known failures / untested scenarios are explicit.
  • User-facing rollback strings and both mobile locales are included; local owner guides document focused verification.

Tant and others added 9 commits October 9, 2026 16:07
The host half of `rollback_session_to_turn` shipped and is locked by the Rust contract tests; the phone could not call it. This adds the client half: the `session_rollback_v1` capability string, a `SessionRolledBackResponse` for `resp: "session_rolled_back"`, the command factory, and the decode from the wire names into the app model.

The field names are asserted literally against the Rust owner (remote_connect.rs:2642-2645, 2770-2775, 3012-3024) instead of being inferred from the app's own types, because the two halves ship separately. The stale guard is omitted rather than sent as null: it is an optional usize on the host, and omitting it only widens the race window, never changes which turn is addressed. No projected mobile message carries a storage index yet, so the parameter stays optional; the host stamps `turn_index` on user messages, and a later package can thread it.

The decoder refuses a payload that is not this answer instead of reading it as a rollback that retired nothing. An older host answers an unknown command with `resp: "error"`, which the generic transport already throws on; anything else must not pass for success.
Staged rollback (frozen decision 4 of the mobile copy/rollback plan) sends nothing to the host when it starts: the timeline shows local ghost rows, the composer is refilled with the withdrawn prompt, and three exits decide what actually happens - close (composer back to the pre-staging snapshot, the refilled prompt is not kept), send (roll back, then send the new text), confirm (roll back, keep the composer text). There is no confirmation dialog.

The policy is pure and returns typed effects for the viewmodel to apply, which is what keeps every exit host-testable. Gating mirrors the Web UI's SessionRollbackService (a dispatch session and a running turn are both denied) and adds the mobile-only capability requirement; the denial order reports the reason the user can act on first, so a busy session on an older host is not blamed on the host.

Confirm and send leave staging before the host answers, so a stale-view guard and a failure stay meaningful in either state: both repair the transcript, and a failure also asks for a visible notice instead of failing silently. Staging a second message replaces the target and keeps the first composer snapshot, because closing must restore the composer as it was before staging began.

Verified: `node --test tools/tests/session-rollback-policy.test.cjs` (19 tests, written first), the full mobile host suite, `pnpm run harmony:architecture` and `assembleHap`. Nothing references the policy from the app graph yet, so ArkTS was made to compile it for one build (CompileArkTS produced its module with no errors) before that probe was reverted.
The staged rollback's gating and state machine shipped with nothing to drive
them. This is the pane that does: the entry on every user-message surface, the
local preview, the bar above the composer, and the three exits.

Entries. The item rides the system selection menu through `Text.editMenuOptions`
(the menu a long press already raises for selection) on the short bubble and on
the full-text panel, and joins the truncated bubble's own long-press menu through
`UserMessageMenuPolicy`. One item id and one label key are shared by all three
surfaces, so a rename cannot leave one of them behind. The item is always
offered: whether a rollback can run is a fact about the connected host and the
session, which the tap answers with a refusal the user can read. The panel closes
as staging begins, because the preview belongs to the conversation behind it.

Staging state lives in the view, as three `@Local` primitives that are
re-projected into a `SessionRollbackState` for every event. The reducer keeps
every rule - which exits exist, what each does to the composer, what a host
outcome means after the exit - and the pane keeps only what it must remember
between events. The target turn id doubles as "is a rollback staged", which is
why the entry refuses to stage a message with no turn to address.

The preview is derived, never stored: the target turn and every row after it are
found by row key against the list the pane is showing right now, so the cut can
never disagree with the rows on screen. Ghost rows keep their layout and drop to
0.58 opacity; the user bubble also swaps its fill and ink, which is the approved
artboard's own pairing.

Confirm and send leave staging before the host answers - the preview has to react
instantly - so the answer comes back through projected state (`rollbackOutcome`
plus a monotonic sequence) and is consumed exactly once, even while idle: a stale
target and a refusal both mean the local transcript describes history the host no
longer has. The host's own two refusal shapes are told apart by its words
(coordinator.rs:8760/8768/8773), and anything unclassified stays a failure rather
than a silent retry. A successful rollback refetches the transcript, because this
client reads `historyRewritten` as false and only compares message counts, so
nothing else would remove the retired rows.

Send while staging is one command with two steps in that order, because the host
has to retire the turn before a message may be appended to it. A send that does
not go out keeps the composer text and marks its own row, which is the
recoverable state; the rollback it followed still reports what it withdrew.

Verified: `node --test "tools/tests/*.test.cjs"` (382 tests, 381 pass, 1 failure
- the pre-existing `workspace-editor.test.cjs` ratchet), `pnpm run
harmony:architecture`, and `assembleHap` (CompileArkTS clean; SignHap still fails
on the missing local signing configuration). No device run, and the wire path is
not exercised end to end here.
The staged rollback needs device evidence, and no paired host is reachable on
the emulators, so the fixture stages it against a mock command layer.

- RollbackPreviewScenario holds the variant table, a mock transcript whose
  short and long user prompts both carry turn ids, and a target-aware canned
  SessionRolledBackResult: the retired turns and the composer text follow the
  turn the request names.
- RollbackStagingPreview runs the production ConversationView over that
  transcript with the real RemoteChatCommandController as the fake command
  layer, keeps the pane honest about rollback and resync outcomes, and prints
  the intent, the mock host's call order, the draft, the notice and the toast
  in a diagnostic strip.
- Variants cover success, accepted send, failed send after a rollback, failed
  rollback, stale target, busy session and a host without the rollback
  capability, each with a dark scenario id.
- rollback-preview.test.cjs guards the scenario table and the registration
  drift, and proves the mock host sees the rollback before the send it was
  staged with.
Measured on device, tapping the rollback item in a user bubble's system
selection menu staged the rollback but left the system overlay open over the
preview it had just asked for: returning true from onMenuItemClick cannot
dismiss a menu the app does not own.

The bubble and the full-text panel now build their Text with a
TextController, and RollbackSelectionMenu.options dismisses the menu through
that controller before it asks for the rollback, so the three entry surfaces
leave the conversation clear behind them.
@wgqqqqq
wgqqqqq merged commit c39bfb3 into GCWing:main Oct 13, 2026
13 checks passed
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