Mobile group actions: captured selection and the durable journal¶
This page records how the Flutter client mirrors the browser's captured selection and durable group execution (Browser group actions). The operational rules stay in AGENTS.md; this page explains where the native code meets them and where the evidence lives.
Selection controls¶
flutter/lib/model/mail_selection.dart remains the bounded controller: one request in flight, at most 32 queued gestures, 50 observed rows, explicit arrival recapture, aliases, stale and lost observations, and release. The membership itself lives in the native SQLite capture (flutter/rust/src/selection.rs); nothing in Dart holds a whole inbox.
Workspace now owns the controller (selection) and feeds it the current scope (folder, account, query, filter, order and the pending individual edits). Every rendered MailTile registers itself as an observation while it is mounted and withdraws when it leaves the list, so observations follow the visible page only. Changing folder, account, filter, order or query ends selection mode; a refresh re-observes the rendered rows and arrivals stay unselected until they are chosen explicitly. Choosing an arrival that lies outside a prior capture extends that capture without discarding the existing selection.
Controls, all visible and labelled: the Select button beside Search enters and leaves selection mode; the toolbar shows the count (All 130 selected, 3 selected, No messages selected), Select all, Clear and Done plus the seven group actions. Rows show a desktop-style 16 px checkbox (4 px radius, subtle border, accent fill) labelled Select <subject>/Deselect <subject>, and their semantics label ends with selected/not selected. A tap in selection mode toggles the row without opening the reader; a long press enters selection mode or, once an anchor exists, extends the range from the anchor like Shift+click. The row's Actions menu carries Select up to here as the visible equivalent of that gesture, and swipes are disabled while selecting. Selection never reads a message body and there are no keyboard shortcuts on mobile to consume.
Select all is a captured all gesture on the SQLite capture; it never loops over loaded rows. On the web preview the count clamp had to become a literal bound because dart2js shifts are 32-bit.
Durable journal¶
flutter/rust/src/groups.rs stores group work in the profile database (schema25) in dedicated tables: group_jobs (action, approved fields, state, scope, approval and Undo revisions, revision counter), group_items (frozen position, cache id, account, physical folder and UID, baseline flags, state, claim attempt, receipt, reason), mail_intents (per-message per-field revisions reserved by individual actions) and group_clock. The tables hold metadata and physical identities only, never MIME or secrets. Keeping them beside the mail cache lets each receipt and its cache write commit in one transaction under the writer FIFO; the ephemeral TEMP capture stays separate.
Commands travel through the existing request bridge as {"op":"groups","command":{...}}:
preparefreezes the live capture at its expected revision (selection::Freeze), copies it in pages of 50 intogroup_itemswith each message's current physical identity, records rows that are no longer cached as skipped so the review count is exact, releases the temporary frozen copy and leaves the job inreview. The original capture stays with the selection controller through failure or dismissal. A failed page marks the jobinterrupted; interrupted preparation cannot execute.inspectreturns the exact saved job in one read transaction. It cannot approve, recover a live owner or execute work. Lost preparation or approval replies use this identity before another decision.approveverifies every account still exists, bumps the group clock and makes the jobrunning. From that moment the paging plan projects the job's fields over its pending items (newest job first per field, individual edits above both), so the list and its counts paint before any step runs.declineretires the review in 50-row transactions.stepexecutes at most one owned step underOperations.groups. The decision is read-only first: account removed, message no longer cached, physical identity changed since the review, a newer individual intent on every target field, or values already applied become distinct skipped outcomes without a provider call. Otherwise the item is claimed (sending/reversingwith an attempt token) and dispatched through the shared per-messageoperations::mutate, which keeps the account lock,pending_moves,MoveReceiptand destination recovery behaviour of individual actions. IMAP steps need the account credential; without it the reply names the account and Dart supplies it for that one step. The receipt records the dispatch identity, the applied fields and the identity afterwards. A provider reply that cannot be classified for a move becomesuncertainand pauses the group; flag failures arefailedand retryable; a warning that the cache could not save is kept on the receipt.undobumps the clock, cancels unsent items before any provider call, queues acknowledged items as inverse steps and lets a step acknowledged after the decision join the inverse queue. Inverse steps compare the current identity with the receipt'safteridentity (or only the folder when an acknowledged move still awaits UID recovery), restore only the fields the receipt applied and respect newer individual intent.pause,resume,retry(failed steps only),accept(unconfirmed steps only; retires the local intent without classifying the provider result),history(newest 20 jobs with per-state counts),items(50 per page) andremove(finished, cancelled or interrupted jobs, retired in 50-row transactions).
Restart: opening the profile marks claimed sending/reversing items uncertain/undo_uncertain, pauses their group, marks staging jobs interrupted, cancels abandoned reviews and sweeps retired jobs in bounded transactions. Nothing is repeated automatically. Preparing a new review retains completed receipts and refuses a twenty-first active group. Retry/Undo reactivation observes the same active bound. Schema25 prevents an older binary reopening a profile whose retained history its admission policy would otherwise delete.
Account removal takes Operations.groups before the account lock so no owned step can dispatch or write a receipt meanwhile; the removal review counts the account's queued, in-flight, failed, uncertain and inverse work as groups, requires the explicit discard confirmation for them, cancels those items, abandons reviews that froze the account and leaves completed receipts alone.
Flutter controller and controls¶
Approved groups offer Undo for pending or sending work before any acknowledgement, including the paused notice and History. The local transaction cancels pending items, removes forward projection and keeps sending items until their actual receipt can join the inverse queue. Newer individual fields remain authoritative. Same-group native Undo retries return its current status without advancing the original Undo revision. Dart retains at most 32 exact decision requests plus one latest capacity rejection, and inspects unknown outcomes before any retry. A dedicated Retry Undo notice survives History close and unrelated pump errors. Explicit removal clears only that group's retained request; held replies cannot recreate its state or feedback. Explicit Undo/Resume/Retry wakes received during the pump's final History read start the same owner again after it becomes idle.
model/group_history.dart owns one 20-group page and one 50-item page, with
visible Newer/Older groups and Previous/Next messages controls. Indexed native
cursors report exact last pages and retain surviving boundary rows after
removals. Details keep their exact group, item and page generation through
delayed Retry/Accept, page changes and same-group re-entry. Group pages and detail
pages each keep one running read and its latest replacement with a shared idle
future. Command revisions reject old page results and errors after removal or
newer decisions. Detail failures keep their scoped Retry
above the scroller while group controls remain available.
The existing MailGroups owner observes active/attention records separately from
the displayed page, including exact older targets. Its iterative observation
loop has the same command fence and coalescing bound.
Accept, Retry and Remove refresh this independent attention snapshot through the
same coalesced owner; accepting checked state still does not claim server success.
Status indexes keep
completed history out of admission and next-step seeks; each active group offers
one indexed eligible item before the next-step choice. This does not introduce
another dispatcher or authorise replay of an uncertain provider step.
Attention counting still visits matching failed/uncertain entries, and each group summary aggregates its members. Indexed completed-history seeks do not establish a fixed cost for large attention sets or large active groups.
flutter/lib/model/mail_groups.dart drives the journal: it prepares the review from the controller's snapshot, approves or declines, pumps steps until the journal is idle or paused (coalescing repaints to one per 250 ms and refreshing History every ten steps), announces completion for six seconds, and exposes Undo, Pause, Resume, Retry, Accept, Remove, History and item pages. NativeRepository.groupStep resolves requires_credentials with the device credential store for one step. Saved groups recover at startup: runnable groups continue, paused groups wait in History.
flutter/lib/ui/mail_groups.dart holds the selection toolbar, the frozen review dialog (counts per account and folder, Cancel and the action verb), the progress notice with Pause, Undo and a History shortcut, the paused/attention notices, the completion toast with Undo, and the Group History screen (20 cards with status, Pause/Resume/Undo/Remove, expandable 50-item pages with Retry and Accept current state). Everything uses the theme tokens and ShepIcons; there are no Material switches or elevation.
Evidence¶
flutter/rust/src/groups_tests.rs: exact 125-row staging and painting, newer-intent and already-applied skips, Undo with cancelled and reversed items, credential requests, unconfirmed pauses that never repeat, explicit retry of definite flag failures with inverse receipts, restart classification and abandoned-review retirement, the removal fence with a held provider step, the 20-job and 50-item bounds.flutter/test/mail_groups_test.dart(controller over the synthetic journal),flutter/test/bulk_controls_test.dartwithtest/support/bulk_controls_scenario.dart(real controls in light and dark: checkbox, long-press range, Select all, review counts, decline, immediate paint, Pause/Resume, Undo, injected failed and unconfirmed steps, History retry and acceptance) and the FFI journal case innative_repository_test.dart.flutter/integration_test/bulk_android_test.dartthroughscripts/clients/android_e2e.py --bulk-only(android_bulk_fixture.pyhands over a 130-message POP3 profile; the native SQLite journal executes, undoes and survives reopening) and the Appium flowflutter/e2e/bulk_native.mjs.flutter/e2e/bulk.mjsthroughscripts/clients/flutter_web_e2e.py --bulkagainsttest/bulk_main.dart.
test/support/group_repository.dart is the synthetic journal used by previews and host tests; it mirrors the Rust contract but is not the production path.
Limitations¶
Review keys are a browser and desktop matter. Each IMAP step opens its own provider session through the shared mutation path, so large IMAP groups are slow; cross-account moves, large-group performance and Apple execution remain open. The live capture remains available through preparation and declined or dismissed reviews. Approval releases only its matching capture after durable confirmation; Done, scope changes and disposal release their own capture. Group intent paints after the durable decision is saved, not before it as the browser does. A cache save failure after a provider acknowledgment is kept on the receipt as a warning rather than a separate repair queue.