diff --git a/app/application.ml b/app/application.ml
index 04ed980..13358b6 100644
--- a/app/application.ml
+++ b/app/application.ml
@@ -2501,10 +2501,10 @@ let composer_content
[ V.of_lui
(Lui_elements.button_group ~gap:0 ~height:44 ~background:"glass"
~corner_radius:999
- ([ action ~label:"文件" ~icon:"doc" Journal_asset_import.Files
- ; action ~label:"照片" ~icon:"photo" Journal_asset_import.Photos ]
+ ([ action ~label:"Files" ~icon:"doc" Journal_asset_import.Files
+ ; action ~label:"Photos" ~icon:"photo" Journal_asset_import.Photos ]
@ if assets.camera then
- [ action ~label:"相机" ~icon:"camera" Journal_asset_import.Camera ]
+ [ action ~label:"Camera" ~icon:"camera" Journal_asset_import.Camera ]
else []))
|> V.with_test_id (Ui.Test_id.string (scope ^ "-attach"))
]
@@ -2718,6 +2718,7 @@ let timeline_page
~status_name
?current_day
?store:timeline_store
+ ?on_sync:(if interaction_enabled then Some (bind_action on_account_action Begin_online_recovery) else None)
~on_region
~render_source
~render_title
diff --git a/app/journal_header.ml b/app/journal_header.ml
index e973b17..658bff5 100644
--- a/app/journal_header.ml
+++ b/app/journal_header.ml
@@ -190,6 +190,11 @@ type presentation =
; capture_enabled : bool
}
+let sync_active = function
+ | Some (Graph_service.Connecting | Pulling | Submitting) -> true
+ | Some (Offline | Current | Paused | Failed) | None -> false
+;;
+
let view_impl
~presentation_signal
~key
@@ -429,23 +434,21 @@ let view_impl
~key
~props_for:(fun presentation ->
props ~mode:"page" ?title:(match context with Journals -> None | Favorites -> Some "Favorites")
- ~connecting:(presentation.sync_phase = Some Graph_service.Connecting)
+ ~connecting:(sync_active presentation.sync_phase)
~controls:(controls presentation) ()) (* Keep absent slots mounted so the native child indexes stay stable. *)
~children:
[ V.Body.Private.to_widget body
; cluster
; dynamic
~equal:(fun left right ->
- left.sync_phase
- = Some Graph_service.Connecting
- = (right.sync_phase = Some Graph_service.Connecting))
+ sync_active left.sync_phase = sync_active right.sync_phase)
(fun presentation ->
V.column
- [ (if presentation.sync_phase = Some Graph_service.Connecting
+ [ (if sync_active presentation.sync_phase
then
V.progress ~style:Circular ()
|> V.semantics
- ~properties:(Ui.Semantics.create ~label:"Connecting" ())
+ ~properties:(Ui.Semantics.create ~label:"Syncing" ())
|> test_id "journal-header-sync-progress"
else V.empty ())
])
diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml
index e52c9fa..c472f8c 100644
--- a/app/journal_native_collection.ml
+++ b/app/journal_native_collection.ml
@@ -20,6 +20,7 @@ let view
~rows
~scroll_target
?scroll_target_signal
+ ?on_sync
~on_scroll_completed
~actions_enabled
~on_visible_range
@@ -161,6 +162,7 @@ let view
?scroll_request_signal
~on_scroll_completed
~on_visible_range
+ ?on_sync
sections in
(match scroll_target_signal with
| None -> list ()
diff --git a/app/journal_native_collection.mli b/app/journal_native_collection.mli
index e59466a..fd08435 100644
--- a/app/journal_native_collection.mli
+++ b/app/journal_native_collection.mli
@@ -14,6 +14,7 @@ val view
-> rows:row list
-> scroll_target:(int64 * string * string) option
-> ?scroll_target_signal:(int64 * string * string) option Signal.signal
+ -> ?on_sync:Ui.Event.Handler.t
-> on_scroll_completed:Ui.Event.Handler.t
-> actions_enabled:bool
-> on_visible_range:Ui.Event.Handler.t
diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml
index ff4d5cb..2ebc885 100644
--- a/app/journal_timeline.ml
+++ b/app/journal_timeline.ml
@@ -208,6 +208,7 @@ let view
?status_name
?current_day
?store
+ ?on_sync
?(on_region = fun _ -> ())
~render_source
?render_title
@@ -366,6 +367,7 @@ let view
~rows
~scroll_target:target
?scroll_target_signal
+ ?on_sync
~on_scroll_completed
~actions_enabled:(delete_enabled && actions_enabled)
~on_visible_range
diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli
index 3398b19..8b828de 100644
--- a/app/journal_timeline.mli
+++ b/app/journal_timeline.mli
@@ -18,6 +18,7 @@ val view
: ?status_name:(Journal_model.task_state -> string)
-> ?current_day:int
-> ?store:Store.t
+ -> ?on_sync:Ui.Event.Handler.t
-> ?on_region:(string -> unit)
-> render_source:(string -> string)
-> ?render_title:(string -> Journal_model.Title.t)
diff --git a/app/journal_view.ml b/app/journal_view.ml
index 9ddd331..aebbfc1 100644
--- a/app/journal_view.ml
+++ b/app/journal_view.ml
@@ -1615,7 +1615,8 @@ module View = struct
[@@deriving yojson]
type props =
{ style : string; sections : section list
- ; track_visible_range : bool; track_scroll_completion : bool }
+ ; track_visible_range : bool; track_scroll_completion : bool
+ ; sync_enabled : bool }
[@@deriving yojson]
type row_event = { row : string; key : string } [@@deriving yojson]
type event =
@@ -1633,7 +1634,7 @@ module View = struct
;;
(* Content indexes retain the native child ownership order. *)
- let build sections ~style ~track_visible ~track_scroll =
+ let build sections ~style ~track_visible ~track_scroll ~sync_enabled =
let contents = ref Rrbvec.empty in
let next_content_index = ref 0 in
let push element =
@@ -1660,7 +1661,7 @@ module View = struct
in
let wire : Json.props =
{ style = style_name style; sections = List.map section_wire sections
- ; track_visible_range = track_visible; track_scroll_completion = track_scroll }
+ ; track_visible_range = track_visible; track_scroll_completion = track_scroll; sync_enabled }
in
Json.props_to_yojson wire |> Yojson.Safe.to_string, Rrbvec.to_list !contents
;;
@@ -1697,6 +1698,7 @@ module View = struct
?scroll_request_signal
?on_scroll_completed
?on_visible_range
+ ?on_sync
sections
=
let payload, contents =
@@ -1705,6 +1707,7 @@ module View = struct
~style
~track_visible:(Option.is_some on_visible_range)
~track_scroll:(Option.is_some on_scroll_completed)
+ ~sync_enabled:(Option.is_some on_sync)
in
(* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool};
the owning row's handler receives Bool like the old disclosure callback. *)
@@ -1746,6 +1749,7 @@ module View = struct
| Error source -> report_event_error "decodeListEvent" source
| Ok wire ->
match wire.event_type with
+ | "sync" -> Option.iter (fun handler -> invoke handler Event.Payload.Unit) on_sync
| "visible_range" ->
(match wire.first, wire.last with
| Some first_index, Some last_exclusive
diff --git a/app/journal_view.mli b/app/journal_view.mli
index 91a6402..eb5aa76 100644
--- a/app/journal_view.mli
+++ b/app/journal_view.mli
@@ -705,6 +705,7 @@ module View : sig
-> ?scroll_request_signal:scroll_request option Signal.signal
-> ?on_scroll_completed:Event.handler
-> ?on_visible_range:Event.handler
+ -> ?on_sync:Event.handler
-> section list
-> t
end
diff --git a/apple/Info.plist b/apple/Info.plist
index 1c7386e..a84608e 100644
--- a/apple/Info.plist
+++ b/apple/Info.plist
@@ -29,7 +29,7 @@
NSCameraUsageDescription
Take a photo to attach to a journal entry.
NSPhotoLibraryAddUsageDescription
- 将你选择的图片保存到相册。Save the image you select to your photo library.
+ Save the image you select to your photo library.
UILaunchScreen
UISupportedInterfaceOrientations
diff --git a/docs/agent-guide/implemented/feature/2026-10-24-timeline-pull-sync-icon.md b/docs/agent-guide/implemented/feature/2026-10-24-timeline-pull-sync-icon.md
new file mode 100644
index 0000000..037091a
--- /dev/null
+++ b/docs/agent-guide/implemented/feature/2026-10-24-timeline-pull-sync-icon.md
@@ -0,0 +1,98 @@
+# Timeline pull sync through the shared owner
+
+## Problem
+
+Timeline has no deliberate pull-at-top entry into the existing sync owner.
+Online recovery currently inspects local sync state without guaranteeing a fresh
+pull, and busy force intent is not retained. The existing header spinner only
+covers Connecting.
+
+## Proposal
+
+The user approved design v3 and implementation plus isolated A135 acceptance on
+2026-10-24. The later English-default requirement sets the top drag hints to
+Pull to sync, then Release to sync at the threshold;
+hide it at release/cancel. Reuse only the existing Options-left loading component.
+Extend the existing Online_recovery_requested path and private pull-owner facts:
+online forces a check, offline uses existing admission/connection recovery, busy
+intent coalesces until the shared owner can send. Existing response/authoritative
+commit and sync phases own completion. Do not add a refresh state machine or
+per-refresh public contract. No spec/ or Dune changes are needed or authorized.
+Use the native List's existing container geometry/phase observers for the small
+trigger and text overlay; retain native scrolling, delegate, rows and anchors.
+
+## Decision
+
+Implemented the optional Timeline callback through Application's existing
+Begin_online_recovery action and Online_recovery_requested event. The shared Core
+retains one private forced intent until its existing pull owner can send; the
+existing response and authoritative commit own completion. The existing header
+progress node now spans Connecting, Pulling and Submitting without replacement.
+No protected spec/ or Dune change, independent refresh state or top spinner.
+
+The default native accessibility action is Sync. The English-default audit also
+replaces unconditional Capture attachment labels with Files, Photos and Camera,
+and the photo-add permission description with English. Existing locale-selected
+Chinese image-preview translations and graph/test contents remain intact.
+
+The existing public Core contract first reproduced the missing force behavior,
+then passed with online, busy, commit, offline/scope, cancel/shutdown and submission
+barrier cases. Header identity and optional native callback checks pass in their
+existing targets. Full dune build @all and dune runtest --force pass.
+
+A135 iPhone 13/iOS26.1 ran the actual production List, Timeline, header and Core
+in a Git-external synthetic IO fixture. Public XCTest touch drags verified short
+pull cancellation, armed release, both hints in actual recorded frames,
+online/no-change, offline/reconnect/apply, busy repeats, page exit/return, deep
+scroll non-trigger and stable first-row position. Actual system Reduce Motion
+was enabled for short/long physical drags and restored to its original off value.
+The complete flow and Reduce Motion tests both pass. CUA coordinate dragging was
+ineffective, so the explicitly authorized official XCTest path was used.
+
+## Alternatives considered
+
+### Default refreshable and a separate per-check contract
+
+Rejected by the updated user design: a second top spinner and independent refresh
+state are unwanted. The original spec extension proposal remains external evidence
+only and is not applied. Shared sync phase is sufficient for the reused activity
+indicator when the actual shared owner retains force and owns completion.
+
+## Acceptance criteria
+
+- Below-threshold release/cancel creates no sync request; armed release creates
+ one shared trigger and hides the hint immediately.
+- Online pulls, offline reconnect/open/pull, and busy/repeated coalescing use the
+ existing owner and transport; no parallel connections or dropped forced intent.
+- Loading is one existing header component, derived from active shared sync phases.
+ Queue admission, old Current and socket open never imply completion; response
+ and required authoritative apply retain Pulling until the shared work settles.
+- Graph/lifecycle exit retires or fences intent; no refresh continuation or endless
+ independent spinner is introduced. Existing errors/Diagnostics remain shared.
+- Stable List/row identity, anchors, return-top cancellation and Reduce Motion remain.
+- Write meaningful failing public Core checks first, then minimal implementation,
+ passing checks and full applicable local validation in existing test targets.
+- Use A135 only with isolated synthetic fixture, no accounts/real graphs, no new
+ device, 1446, reset/deletion or OS permission changes. Record actual implemented
+ native UI, with accurate coverage limits. No push/PR/merge/TestFlight.
+
+## Consequences
+
+- Native List overscroll remains system-owned. Only semantic hint changes update
+ view state; the overlay reserves no layout space and no per-row geometry exists.
+- Keep the unrelated error-icon branch separate; main remains d37ee9c as fetched.
+- Reuse prepared build inputs without changing another owner's switches or caches.
+- Simulator host-restamping has cross_abi_validated=false; this is execution
+ evidence, not a validated cross-compiled iOS release artifact. Synthetic auth,
+ transport and apply completions do not establish live service/account acceptance.
+- No performance measurement, live graph mutation or dedicated native blocking
+ error scenario is claimed. Existing shared error/Diagnostics behavior remains.
+- Evidence, temporary XCTest, original videos, result bundles and build manifests
+ are outside the repository in the task's implementation-evidence directory.
+ The final actual-gesture recording supersedes the earlier partial AX recording;
+ neither it nor the earlier design movie claims production network verification.
+
+## Questions
+
+None. The latest user approval answers the implementation/isolated A135 scope.
+The obsolete per-check protected-spec proposal is not part of this design.
diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml
index b13f3cd..5140bcf 100644
--- a/logseq_sync/lib/pure_reducer/core.ml
+++ b/logseq_sync/lib/pure_reducer/core.ml
@@ -891,6 +891,7 @@ type t =
; submission_owner : submission_owner option
; pull_owner : pull_owner option
; pull_wanted : (graph_scope * Server_cursor.t) option
+ ; force_pull_pending : bool
; diagnostic_remote_cursor : (graph_scope * Server_cursor.t) option
; pull_retry_delay : float
; active_authoritative_batch : authoritative_batch option
@@ -967,7 +968,7 @@ let initial config =
; pending_sync_inspection = None
; pending_outbox_transition = None
; pull_owner = None
- ; pull_wanted = None
+ ; pull_wanted = None; force_pull_pending = false
; diagnostic_remote_cursor = None
; pull_retry_delay = 1.
; submission_owner = None
@@ -1081,7 +1082,7 @@ let fail core stage message =
{ core with
pending_effects = []
; pull_owner = None
- ; pull_wanted = None
+ ; pull_wanted = None; force_pull_pending = false
; pull_retry_delay = 1.
; pending_mirror_inspection = None
; pending_selected_download = None
@@ -1281,7 +1282,7 @@ let reset_graph_operations core =
; pending_outbox_transition = None
; submission_owner = None
; pull_owner = None
- ; pull_wanted = None
+ ; pull_wanted = None; force_pull_pending = false
; pull_retry_delay = 1.
; active_authoritative_batch = None
; queued_authoritative_batch = None
@@ -1652,7 +1653,8 @@ let pull_target_pending core graph cursor =
let pull_in_progress core =
match core.current_graph_scope, core.public_state.snapshot.applied_server_t with
| Some graph, Some cursor ->
- Option.is_some core.pull_owner
+ core.force_pull_pending
+ || Option.is_some core.pull_owner
|| Option.is_some core.active_authoritative_batch
|| pull_target_pending core graph cursor
| _ -> false
@@ -1843,7 +1845,11 @@ let request_pull ?through ?(force = false) core =
if
occupied
|| Option.is_some core.active_authoritative_batch
- || not (force || pull_target_pending core graph cursor)
+ || (core.force_pull_pending && not force
+ && not (pull_target_pending core graph cursor)
+ && (Option.is_some core.pending_outbox_transition
+ || Option.is_some core.submission_owner))
+ || not (force || core.force_pull_pending || pull_target_pending core graph cursor)
then unchanged core
else (
let connection = { graph; connection_generation = core.connection_generation } in
@@ -1852,6 +1858,7 @@ let request_pull ?through ?(force = false) core =
set_snapshot
{ core with
next_effect_id = timer + 1
+ ; force_pull_pending = false
; pull_owner =
Some
{ pull_connection = connection
@@ -3036,7 +3043,7 @@ let step core event =
pending_selected_download = None;
pending_effects = local_pending; websocket_live = false; deferred_catalog_reconciliation = false;
unlock_preparation = No_unlock_preparation;
- bootstrap = (if bootstrap_downloading next.bootstrap then No_bootstrap else next.bootstrap); pull_wanted = None;
+ bootstrap = (if bootstrap_downloading next.bootstrap then No_bootstrap else next.bootstrap); pull_wanted = None; force_pull_pending = false;
pull_owner = (match next.pull_owner with
| Some {pull_stage=Applying_pull; _} as owner -> owner | _ -> None)
} { snapshot with timeline_presentation_pending = false; startup; sync_phase = Offline
@@ -3170,7 +3177,12 @@ let step core event =
in
let requested = begin_e2ee_key_access core scope in
{ requested with effects = publish requested.next :: requested.effects }
- | Some scope, _ -> inspect_sync core scope
+ | Some scope, _ ->
+ (* The existing explicit-online entry owns manual freshness demand.
+ Busy work keeps a single intent until its response/commit retires. *)
+ let inspected = inspect_sync { core with force_pull_pending = true } scope in
+ let pulled = request_pull inspected.next in
+ { pulled with effects = inspected.effects @ pulled.effects }
| None, _ -> unchanged core)
| Local_cache_deletion_requested graph_id -> begin_local_deletion core graph_id
| Foreground_changed { foreground = false; lifecycle_generation } ->
@@ -3309,7 +3321,7 @@ let step core event =
; pending_outbox_transition = None
; submission_owner = None
; pull_owner = None
- ; pull_wanted = None
+ ; pull_wanted = None; force_pull_pending = false
; pull_retry_delay = 1.
; active_authoritative_batch = None
; queued_authoritative_batch = None
@@ -3984,6 +3996,14 @@ let step_sync = step
let step core event =
let transition = step_sync core event in
+ let transition =
+ if transition.next.force_pull_pending && transition.next.foreground
+ && not transition.next.closed && network_allowed transition.next
+ then
+ let pulled = request_pull transition.next in
+ { pulled with effects = transition.effects @ pulled.effects }
+ else transition
+ in
let public_state = with_sync_cursor_diagnostics transition.next transition.next.public_state in
let next = { transition.next with public_state } in
let effects = List.map (function
diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml
index 1f49f5d..8aaaef6 100644
--- a/logseq_sync/test/core_contract.ml
+++ b/logseq_sync/test/core_contract.ml
@@ -3799,8 +3799,129 @@ let pull_shutdown_and_graph_retirement_cancel_demand () =
[ Core.Shutdown; Core.Graph_picker_requested ]
;;
+(* The existing explicit-online event is the shared manual-sync entry. *)
+let manual_sync core =
+ Core.step core (Core.Online_recovery_requested (timeline_scope core))
+;;
+
+let manual_sync_online_forces_a_fresh_pull () =
+ let opened, connection, _ = pull_fixture () in
+ let current = pull_response opened.next connection ~after:0 0 in
+ let requested = manual_sync current.next in
+ check_pull_count "manual online sync sends a fresh pull" 1 requested.effects;
+ Alcotest.(check bool) "request is not prematurely Current" true
+ ((Core.state requested.next).snapshot.sync_phase = Core.Pulling);
+ let completed = pull_response requested.next connection ~after:0 0 in
+ check_pull_count "one confirmed empty response settles" 0 completed.effects;
+ Alcotest.(check bool) "confirmed empty pull is Current" true
+ ((Core.state completed.next).snapshot.sync_phase = Core.Current)
+;;
+
+let manual_sync_busy_force_is_coalesced () =
+ let opened, connection, _ = pull_fixture () in
+ let pending = ref opened.next in
+ for _ = 1 to 50 do
+ let requested = manual_sync !pending in
+ check_pull_count "busy gestures never send concurrently" 0 requested.effects;
+ pending := requested.next
+ done;
+ let received = pull_response !pending connection ~after:0 0 in
+ check_pull_count "force survives old empty response" 1 received.effects;
+ Alcotest.(check bool) "post-gesture probe remains Pulling" true
+ ((Core.state received.next).snapshot.sync_phase = Core.Pulling);
+ let confirmed = pull_response received.next connection ~after:0 0 in
+ check_pull_count "fifty gestures merge into one probe" 0 confirmed.effects;
+ Alcotest.(check bool) "new probe confirmation settles" true
+ ((Core.state confirmed.next).snapshot.sync_phase = Core.Current)
+;;
+
+let manual_sync_waits_for_authoritative_commit () =
+ let opened, connection, sync = pull_fixture () in
+ let received = pull_response opened.next connection ~after:0 1 in
+ let requested = manual_sync received.next in
+ check_pull_count "applying authoritative data keeps its owner" 0 requested.effects;
+ let committed = recovery_apply requested.next connection
+ (recovery_sync sync ~checkpoint:1 1 []) in
+ Alcotest.(check bool) "probe starts from committed cursor" true
+ (pull_sends committed.effects = [connection, Some (server_cursor_of_int 1)]);
+ Alcotest.(check bool) "no intermediate Current publication" false
+ (List.exists (function Core.Publish (Core.State_changed state) ->
+ state.snapshot.sync_phase = Core.Current | _ -> false) committed.effects);
+ let confirmed = pull_response committed.next connection ~after:1 1 in
+ Alcotest.(check bool) "commit plus fresh response settles" true
+ ((Core.state confirmed.next).snapshot.sync_phase = Core.Current)
+;;
+
+let manual_sync_offline_uses_existing_admission_and_socket_owner () =
+ let opened, connection, _ = pull_fixture () in
+ let current = pull_response opened.next connection ~after:0 0 in
+ let closed = Core.step current.next
+ (Core.Websocket_closed (connection, Core.Recoverable_close (Some "synthetic offline"))) in
+ let requested = manual_sync closed.next in
+ check_pull_count "offline never sends through old socket" 0 requested.effects;
+ (* Application's existing online request re-verifies the native identity. *)
+ let admitted = Core.step requested.next
+ (Core.Account_authenticated {user_id=Some "user"}) in
+ Alcotest.(check bool) "replacement waits for physical retirement" false
+ (List.exists (function Core.Run (Core.Start_websocket _) -> true | _ -> false) admitted.effects);
+ let retired = Core.step admitted.next (Core.Websocket_retired connection) in
+ let fresh = recovery_connection retired.effects in
+ let connected = Core.step retired.next (Core.Websocket_opened fresh) in
+ check_pull_count "recovered connection owns the single baseline pull" 1 connected.effects;
+ let late = pull_response connected.next connection ~after:0 5 in
+ check_pull_count "old socket response is fenced" 0 late.effects;
+ let completed = pull_response late.next fresh ~after:0 0 in
+ check_pull_count "recovery does not queue an extra manual pull" 0 completed.effects;
+ Alcotest.(check bool) "reconnected confirmation settles" true
+ ((Core.state completed.next).snapshot.sync_phase = Core.Current)
+;;
+
+let manual_sync_scope_and_retirement_fence_force () =
+ let opened, connection, _ = pull_fixture () in
+ let current = pull_response opened.next connection ~after:0 0 in
+ let scope = timeline_scope current.next in
+ let stale = {scope with startup={scope.startup with
+ presentation_generation=scope.startup.presentation_generation+1}} in
+ let ignored = Core.step current.next (Core.Online_recovery_requested stale) in
+ check_instructions "stale manual intent is inert" [] ignored.effects;
+ let requested = manual_sync opened.next in
+ List.iter (fun event ->
+ let retired = Core.step requested.next event in
+ let late = pull_response retired.next connection ~after:0 1 in
+ check_pull_count "retired force cannot restart pull" 0 late.effects)
+ [Core.Graph_picker_requested; Core.Shutdown;
+ Core.Online_cancelled (startup_scope requested.next)]
+;;
+
+let manual_sync_does_not_bypass_submission_owner () =
+ let submitted, connection, sync, batch = submitted_delete_recovery_fixture () in
+ let requested = manual_sync submitted.next in
+ check_pull_count "manual force waits for shared submission response" 0 requested.effects;
+ Alcotest.(check bool) "submission remains active" true
+ ((Core.state requested.next).snapshot.sync_phase = Core.Submitting);
+ let accepted = pull_message requested.next connection
+ (Protocol.Server.Tx_batch_ok {t=server_cursor_of_int 1;checksum=Some "0123456789abcdef"}) in
+ let request = recovery_request accepted.effects in
+ let accepted_sync = recovery_sync sync 3
+ (List.map (fun (item : Overlay.submission_descriptor) ->
+ {item with state=Overlay.Accepted_pending_authoritative (Overlay.submission_batch_id batch)})
+ (Overlay.sync_view_submissions sync)) in
+ let applied = Core.step accepted.next (recovery_commit request accepted_sync None) in
+ check_pull_count "acceptance joins the still-outstanding baseline pull" 0 applied.effects;
+ let received = pull_response applied.next connection ~after:0 1 in
+ let committed = recovery_apply received.next connection
+ (recovery_sync sync ~checkpoint:1 4 []) in
+ check_pull_count "submission and manual force drain through shared commit" 1 committed.effects
+;;
+
let pull_singleflight_scenarios =
- [ Alcotest.test_case
+ [ Alcotest.test_case "manual sync online fresh pull" `Quick manual_sync_online_forces_a_fresh_pull
+ ; Alcotest.test_case "manual sync busy force coalesces" `Quick manual_sync_busy_force_is_coalesced
+ ; Alcotest.test_case "manual sync waits for commit" `Quick manual_sync_waits_for_authoritative_commit
+ ; Alcotest.test_case "manual sync offline admission and owner" `Quick manual_sync_offline_uses_existing_admission_and_socket_owner
+ ; Alcotest.test_case "manual sync stale and retired scope" `Quick manual_sync_scope_and_retirement_fence_force
+ ; Alcotest.test_case "manual sync preserves submission owner" `Quick manual_sync_does_not_bypass_submission_owner
+ ; Alcotest.test_case
"F5 opening and notification burst share one pull"
`Quick
pull_open_and_notifications_are_singleflight
diff --git a/swift/JournalList.swift b/swift/JournalList.swift
index 0698d40..235815c 100644
--- a/swift/JournalList.swift
+++ b/swift/JournalList.swift
@@ -79,6 +79,7 @@ import UIKit
let sections: [SectionModel]
let track_visible_range: Bool?
let track_scroll_completion: Bool?
+ let sync_enabled: Bool?
}
/// One payload observation belongs to one mounted native list. Child-model
@@ -344,7 +345,7 @@ import UIKit
var active = false
var handledToken: Int64 = 0
private var userInteracting = false
- private var atTop = false
+ private(set) var atTop = false
#if os(iOS)
weak var scrollView: UIScrollView?
weak var cover: UIView?
@@ -539,6 +540,8 @@ import UIKit
@State private var scrollProxy: ScrollViewProxy?
@State private var pendingScroll: (request: ScrollRequest, id: String, anchor: UnitPoint)?
@StateObject private var topScroll = TopScroll()
+ @State private var pullEligible = false
+ @State private var pullHint = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
init(context: LUIAppleExtensionViewContext) {
@@ -750,6 +753,34 @@ import UIKit
}
}
+ private func cancelPull() {
+ pullEligible = false
+ pullHint = 0
+ }
+
+ private func scrollPhaseChanged(from previous: ScrollPhase, to phase: ScrollPhase) {
+ let wasTracking = previous == .tracking || previous == .interacting
+ let tracking = phase == .tracking || phase == .interacting
+ if tracking && !wasTracking {
+ pullEligible = properties?.sync_enabled == true && topScroll.atTop
+ pullHint = 0
+ }
+ if wasTracking && !tracking {
+ var cancelled = false
+ #if os(iOS)
+ let nativePhase = topScroll.scrollView?.panGestureRecognizer.state
+ cancelled = nativePhase == .cancelled || nativePhase == .failed
+ #endif
+ let trigger = pullEligible && pullHint == 2 && !cancelled
+ && (phase == .idle || phase == .decelerating)
+ && properties?.sync_enabled == true
+ cancelPull()
+ if trigger { emit(Event(type: "sync")) }
+ }
+ // The existing top-scroll owner retains interaction cancellation.
+ topScroll.interactionChanged(tracking)
+ }
+
var body: some SwiftUI.View {
ScrollViewReader { proxy in
List {
@@ -775,11 +806,34 @@ import UIKit
#if os(iOS)
.overlay(TopScrollAccess(owner: topScroll).ignoresSafeArea().allowsHitTesting(false).accessibilityHidden(true))
#endif
- .onScrollGeometryChange(for: Bool.self) { geometry in
- geometry.contentOffset.y + geometry.contentInsets.top <= 1
- } action: { _, top in topScroll.geometryChanged(top) }
- .onScrollPhaseChange { _, phase in
- topScroll.interactionChanged(phase == .tracking || phase == .interacting)
+ .onScrollGeometryChange(for: CGFloat.self) { geometry in
+ geometry.contentOffset.y + geometry.contentInsets.top
+ } action: { _, offset in
+ topScroll.geometryChanged(offset <= 1)
+ if pullEligible {
+ // Only the three semantic hint states invalidate the view.
+ let hint = offset <= -72 ? 2 : offset <= -20 ? 1 : 0
+ if pullHint != hint { pullHint = hint }
+ }
+ }
+ .onScrollPhaseChange { previous, phase in
+ scrollPhaseChanged(from: previous, to: phase)
+ }
+ .overlay(alignment: .top) {
+ if pullEligible && pullHint > 0 {
+ Text(pullHint == 2 ? "Release to sync" : "Pull to sync")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .padding(.top, 12)
+ .allowsHitTesting(false)
+ .accessibilityIdentifier("journal-pull-sync-hint")
+ .transaction { $0.animation = nil }
+ }
+ }
+ .accessibilityActions {
+ if properties?.sync_enabled == true {
+ Button("Sync") { emit(Event(type: "sync")) }
+ }
}
.onAppear {
topScroll.active = true
@@ -790,6 +844,7 @@ import UIKit
}
}
.onDisappear {
+ cancelPull()
topScroll.active = false
topScroll.interactionChanged(false)
topScroll.cancel()
@@ -799,6 +854,7 @@ import UIKit
topScroll.geometryChanged(false)
}
.onChange(of: context.nodeID) { _, _ in
+ cancelPull()
topScroll.interactionChanged(false)
topScroll.cancel()
pendingScroll = nil
diff --git a/test/application_view_test.ml b/test/application_view_test.ml
index f8f29f7..2480dff 100644
--- a/test/application_view_test.ml
+++ b/test/application_view_test.ml
@@ -2068,7 +2068,7 @@ let run_favorites_native_visibility
scenario
[ "attachments"; "attachment-only"; "offscreen-attachments"; "selection-error"; "picker-owner"; "retry-imports"; "attachment-limit"; "interrupted-imports" ]
then (
- dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "文件")));
+ dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Files")));
let adapter, request =
Hashtbl.fold
(fun id values found ->
@@ -2157,7 +2157,7 @@ let run_favorites_native_visibility
Alcotest.(check (option bool)) (label ^ " enabled matches capacity")
(Some enabled)
(Option.map Yojson.Safe.Util.to_bool
- (List.assoc_opt "enabled" (Hashtbl.find props node)))) ["文件";"照片";"相机"] in
+ (List.assoc_opt "enabled" (Hashtbl.find props node)))) ["Files";"Photos";"Camera"] in
check false;
Alcotest.(check bool) "cap explains how to add another attachment" true
(Option.is_some (find "text" "9 attachments maximum. Remove one to add another."));
@@ -3642,7 +3642,7 @@ let run_favorites_native_visibility
Two Worker lanes make accidental parallel submission observable. *)
dispatch
(Lui_protocol.Press (Option.get (find "accessibility-label" "Capture")));
- dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "文件")));
+ dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Files")));
let adapter =
Hashtbl.fold
(fun id values found ->
@@ -5131,6 +5131,22 @@ let test_reactive_header_current_controls () =
"cleared error capability removes error action"
true
(Option.is_none (find AccessibilityLabel "Error info"));
+ let spinner = ref None in
+ List.iter (fun phase ->
+ ignore (Lui_app.send app {initial with sync_phase=Some phase});
+ ignore (Lui_app.flush app);
+ let current = find AccessibilityIdentifier "journal-header-sync-progress" in
+ (match !spinner with
+ | None -> spinner := current
+ | Some id -> Alcotest.(check (option int))
+ "all active phases retain the same existing spinner" (Some id) current);
+ Alcotest.(check bool) "shared active phase shows header progress" true
+ (Option.is_some current))
+ [Service.Connecting; Pulling; Submitting];
+ ignore (Lui_app.send app {initial with sync_phase=Some Service.Current});
+ ignore (Lui_app.flush app);
+ Alcotest.(check bool) "settled shared owner removes progress" true
+ (Option.is_none (find AccessibilityIdentifier "journal-header-sync-progress"));
Alcotest.(check int)
"control changes preserve body node identity"
body
diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml
index 0edd12a..5ccd971 100644
--- a/test/journal_semantics_test.ml
+++ b/test/journal_semantics_test.ml
@@ -3077,8 +3077,31 @@ let test_status_property_identity_preserved () =
[ G.Uuid.to_string (uuid 99); G.Uuid.to_string (uuid 100) ]
;;
+let test_native_sync_entry_is_bound_and_scoped () =
+ let calls = ref 0 in
+ let no_op = Ui.Event.Handler.create (fun _ -> ()) in
+ List.iter (fun enabled ->
+ let view = Journal_native_collection.view
+ ~key:(Ui.Key.string "sync-list") ~test_id:(Ui.Test_id.string "sync-list")
+ ~rows:[] ~scroll_target:None
+ ?on_sync:(if enabled then Some (Ui.Event.Handler.create (fun _ -> incr calls)) else None)
+ ~on_scroll_completed:no_op ~actions_enabled:true ~on_visible_range:no_op
+ ~on_open:no_op ~on_status:no_op ~on_delete:no_op ~on_copy:no_op ~children:[] ()
+ |> V.Body.Private.to_widget in
+ with_mounted view (fun app ops ->
+ let node = native_list_node (ops ()) in
+ let before = !calls in
+ dispatch_list_payload app node {|{"type":"sync"}|};
+ require (!calls = before + (if enabled then 1 else 0))
+ "native sync trigger did not use the mounted optional callback";
+ dispatch_list_payload app node {|{"type":"unknown-sync"}|};
+ require (!calls = before + (if enabled then 1 else 0))
+ "unknown native event triggered sync")) [false; true]
+;;
+
let tests =
- [ ( "Database status identity preserves complete detail"
+ [ "native sync entry binding", test_native_sync_entry_is_bound_and_scoped
+ ; ( "Database status identity preserves complete detail"
, test_status_property_identity_preserved )
; ( "Typed properties omitted from Timeline"
, test_property_values_are_not_timeline_children )