From cfabeb2c01ddebb43fa980fe47308c6aab538002 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 16 Oct 2026 21:52:21 +0800 Subject: [PATCH 1/3] Scroll to top when reselecting Timeline --- app/application.ml | 6 ++ app/application.mli | 1 + app/journal_native_collection.ml | 41 +++----- app/journal_native_collection.mli | 2 +- app/journal_timeline.ml | 83 ++++++++++++++-- app/journal_timeline_state.ml | 28 ++++-- app/journal_timeline_state.mli | 12 ++- .../JournalTimelineReselectAcceptance.swift | 81 ++++++++++++++++ apple-tests/native-list/README.md | 18 ++++ .../2026-10-16-timeline-tab-scroll-to-top.md | 51 ++++++++++ swift/JournalList.swift | 97 +++++++++++++------ test/application_view_test.ml | 77 ++++++++++++++- test/journal_semantics_test.ml | 4 +- test/journal_timeline_state_test.ml | 2 +- 14 files changed, 428 insertions(+), 75 deletions(-) create mode 100644 apple-tests/native-list/JournalTimelineReselectAcceptance.swift create mode 100644 docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md diff --git a/app/application.ml b/app/application.ml index a8976146..daa0df13 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1460,6 +1460,11 @@ module Root_navigation = struct let step state event = let state = match event with + | Select Journal_routes.Journals + when Journal_routes.destination state.routes = Journals + && Journal_routes.route state.routes = Timeline + && state.modal = No_modal -> + { state with timeline = Journal_timeline_state.scroll_to_top state.timeline } | Select destination -> select_destination state destination | Capture_opened -> let state = @@ -1620,6 +1625,7 @@ module Root_navigation = struct ;; let destination state = Journal_routes.destination state.routes + let timeline state = state.timeline let capture_presented state = state.modal = Capture_sheet let capture state = state.direct_capture let favorites state = state.favorites diff --git a/app/application.mli b/app/application.mli index ac854e50..a12c281b 100644 --- a/app/application.mli +++ b/app/application.mli @@ -132,6 +132,7 @@ module Root_navigation : sig val create : graph_generation:int -> t val step : t -> event -> t val destination : t -> Journal_routes.destination + val timeline : t -> Journal_timeline_state.t val capture_presented : t -> bool val capture : t -> Journal_capture.t option val favorites : t -> Journal_routes.Favorites.t diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index 2cdcf778..88671152 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -18,7 +18,7 @@ let view ~key ~test_id ~rows - ~scroll_target + ~scroll_request ~on_scroll_completed ~actions_enabled ~on_visible_range @@ -137,21 +137,7 @@ let view })) | _ -> ()) in - let scroll_request = - Option.map - (fun (token, section, row) -> - V.Native_list.scroll_request - ~token - ~target: - (V.Native_list.target - ~section:(Ui.Key.string section) - ~row_path:[ Ui.Key.string row ]) - ~anchor:Top - ~animated:false - ()) - scroll_target - in - V.Native_list.vertical + let content = V.Native_list.vertical ~key ~style:Plain ?scroll_request @@ -159,17 +145,14 @@ let view ~on_visible_range sections |> V.Viewport.Vertical.with_test_id test_id - |> V.Viewport.Vertical.overlay - ~key:(Ui.Key.string "journal-empty-presentation") - ~overlay: - (if rows = [] - then - V.content_unavailable - ~label: - (V.label - ~title:(V.text "No journal entries yet") - ~icon:(V.symbol ~name:"book" ()) - ()) - () - else V.empty ()) + in + if rows <> [] then content + else + V.Viewport.Vertical.overlay + ~key:(Ui.Key.string "journal-empty-presentation") + ~overlay: + (V.content_unavailable + ~label:(V.label ~title:(V.text "No journal entries yet") + ~icon:(V.symbol ~name:"book" ()) ()) ()) + content ;; diff --git a/app/journal_native_collection.mli b/app/journal_native_collection.mli index 4357b0e1..e8340d66 100644 --- a/app/journal_native_collection.mli +++ b/app/journal_native_collection.mli @@ -12,7 +12,7 @@ val view : key:Ui.Key.t -> test_id:Ui.Test_id.t -> rows:row list - -> scroll_target:(int64 * string * string) option + -> scroll_request:Ui.View.Native_list.scroll_request option -> 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 01274d73..fe22cad8 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -23,6 +23,30 @@ let should_show_timestamp ~today ~previous_slot = function | Top_level _ | Day_heading _ | Day_continuation _ | Feed_continuation _ -> false ;; +let scroll_parameters = function + | Timeline.Row { token; day; row } -> token, day, [ row ], false + | Timeline.Top { token; day } -> token, day, [], true +;; + +(* Command-only payloads update the mounted native List without resubmitting + its rows/children, which would invalidate native self-sizing mid-scroll. *) +let scroll_command target = + let request = + match target with + | None -> `Null + | Some target -> + let token, day, row_path, animated = scroll_parameters target in + `Assoc + [ "token", `Intlit (Int64.to_string token) + ; "target", `Assoc [ "section", `String (string_of_int day) + ; "row_path", `List (List.map (fun key -> `String key) row_path) ] + ; "anchor", `String "top" + ; "animated", `Bool animated + ] + in + Yojson.Safe.to_string (`Assoc [ "command", `String "scroll"; "scroll_request", request ]) +;; + module Store = struct type row = { entry : Journal_graph_projection.timeline_entry @@ -44,10 +68,11 @@ module Store = struct ; listeners : (string, (int, listener) Hashtbl.t) Hashtbl.t ; observe : string -> unit ; mutable last : Timeline.t option - ; mutable shape : (int * (int64 * int * string) option * shape list) option + ; mutable shape : (int * shape list) option ; mutable revision : int ; mutable epoch : int ; mutable serial : int + ; scroll_listeners : (int, listener) Hashtbl.t } let create ?(observe = fun _ -> ()) () = @@ -59,6 +84,7 @@ module Store = struct ; revision = 0 ; epoch = 0 ; serial = 0 + ; scroll_listeners = Hashtbl.create 1 } ;; @@ -85,6 +111,16 @@ module Store = struct if Hashtbl.length bucket = 0 then Hashtbl.remove t.listeners block_id) ;; + let subscribe_scroll t callback = + t.serial <- t.serial + 1; + let id = t.serial in + let listener = { epoch = t.epoch; active = true; callback } in + Hashtbl.add t.scroll_listeners id listener; + fun () -> listener.active <- false; Hashtbl.remove t.scroll_listeners id + ;; + + let scroll_target t = Option.bind t.last Timeline.scroll_target + let notify t block_id = Option.iter (fun bucket -> @@ -105,9 +141,13 @@ module Store = struct let same = Option.fold ~none:false - ~some:(fun old -> Timeline.equal_presentation old state) + ~some:(fun old -> Timeline.equal_content old state) t.last in + let scroll_changed = + Option.fold ~none:(Option.is_some (Timeline.scroll_target state)) + ~some:(fun old -> Timeline.scroll_target old <> Timeline.scroll_target state) t.last + in t.last <- Some state; if not same then ( @@ -158,13 +198,22 @@ module Store = struct [] in List.iter (Hashtbl.remove t.rows) removed; - let shape = Some (Timeline.today state, Timeline.scroll_target state, shape) in + let shape = Some (Timeline.today state, shape) in if t.shape <> shape then ( t.shape <- shape; t.revision <- t.revision + 1; t.observe "timeline-structure-change"); List.iter (notify t) (List.rev_append !changes removed)); + if scroll_changed then + if same then + Hashtbl.iter (fun _ listener -> + if listener.active && listener.epoch = t.epoch then listener.callback ()) + t.scroll_listeners + else + (* A real content change (including Capture) still submits its complete + row projection together with the command, as before. *) + t.revision <- t.revision + 1; t.revision ;; @@ -172,7 +221,8 @@ module Store = struct t.epoch <- t.epoch + 1; t.last <- None; t.shape <- None; - Hashtbl.clear t.rows + Hashtbl.clear t.rows; + Hashtbl.clear t.scroll_listeners ;; end @@ -317,13 +367,18 @@ let view else presented in let rows, children = List.split presented in - Journal_native_collection.view + let content = Journal_native_collection.view ~key:(Ui.Key.string "journal-timeline-list") ~test_id:(Ui.Test_id.string "journal-timeline") ~rows - ~scroll_target: + ~scroll_request: (Option.map - (fun (token, day, row) -> token, string_of_int day, row) + (fun target -> + let token, day, row_path, animated = scroll_parameters target in + let row_path = List.map Ui.Key.string row_path in + V.Native_list.scroll_request ~token + ~target:(V.Native_list.target ~section:(Ui.Key.string (string_of_int day)) ~row_path) + ~anchor:Top ~animated ()) (Timeline.scroll_target state)) ~on_scroll_completed ~actions_enabled:(delete_enabled && actions_enabled) @@ -333,6 +388,20 @@ let view ~on_delete ~on_copy ~children + in + let content = + match store with + | None -> content + | Some store -> + V.of_lui (fun context parent -> + let node = Ui.mount content context parent in + let unsubscribe = Store.subscribe_scroll store (fun () -> + Lui_ui.extension_property context node "payload" + (Lui_protocol.StringValue (scroll_command (Store.scroll_target store)))) in + Signal.on_dispose context.Lui_ui.ui_scope unsubscribe; + node) + in + content |> V.Body.Vertical.fill |> fun content -> V.Body.Vertical.create [ content ] ;; diff --git a/app/journal_timeline_state.ml b/app/journal_timeline_state.ml index ba93a2e2..01ed4fd8 100644 --- a/app/journal_timeline_state.ml +++ b/app/journal_timeline_state.ml @@ -14,6 +14,10 @@ type slot = } | Feed_continuation of { before_day : int } +type scroll_target = + | Row of { token : int64; day : int; row : string } + | Top of { token : int64; day : int } + type recovery = { day : int ; target_count : int @@ -43,7 +47,7 @@ type t = ; visible_first : int ; visible_last_exclusive : int ; scroll_generation : int64 - ; scroll_target : (int64 * int * string) option + ; scroll_target : scroll_target option ; scroll_outcome : Journal_view.View.Native_list.outcome option ; visible_demand : request list option ; pending : (int64 * request) option @@ -1116,8 +1120,8 @@ let prepend_timeline_entry (state : t) (entry : Journal_graph_projection.timelin Rrbvec.find_map (function | Top_level entry as slot -> - Some (token, Journal_model.journal_day entry.block, slot_key slot) - | Day_continuation { day; _ } as slot -> Some (token, day, slot_key slot) + Some (Row { token; day = Journal_model.journal_day entry.block; row = slot_key slot }) + | Day_continuation { day; _ } as slot -> Some (Row { token; day; row = slot_key slot }) | Day_heading _ | Feed_continuation _ -> None) next.slots } @@ -1186,16 +1190,28 @@ let observe_visible_range (state : t) ~first_index ~last_exclusive = (* Rendering consumes slots/date/errors and the active scroll command, not the latest demand window or recovery bookkeeping. Slots are persistent. *) -let equal_presentation (left : t) (right : t) = +let equal_content (left : t) (right : t) = left == right || (left.slots == right.slots && left.today = right.today && left.day_failures == right.day_failures - && left.scroll_target = right.scroll_target && (left.day_failures = [] || Option.is_some left.pending = Option.is_some right.pending)) ;; +let equal_presentation (left : t) (right : t) = + equal_content left right && left.scroll_target = right.scroll_target +;; + +let scroll_to_top state = + let token = Int64.succ state.scroll_generation in + { state with + scroll_generation = token + ; scroll_target = Some (Top { token; day = state.today }) + ; scroll_outcome = None + } +;; + let retained_slot (state : t) index = Rrbvec.nth_opt state.slots index let fold_slots f initial (state : t) = Rrbvec.fold_left f initial state.slots @@ -1232,7 +1248,7 @@ let scroll_outcome (state : t) = state.scroll_outcome let complete_scroll state ~token ~outcome = match state.scroll_target with - | Some (current, _, _) when current = token -> + | Some (Row { token = current; _ } | Top { token = current; _ }) when current = token -> { state with scroll_target = None; scroll_outcome = Some outcome } | None | Some _ -> state ;; diff --git a/app/journal_timeline_state.mli b/app/journal_timeline_state.mli index 49df1827..d5bcb406 100644 --- a/app/journal_timeline_state.mli +++ b/app/journal_timeline_state.mli @@ -14,6 +14,10 @@ type slot = } | Feed_continuation of { before_day : int } +type scroll_target = + | Row of { token : int64; day : int; row : string } + | Top of { token : int64; day : int } + type t val empty : today:int -> t @@ -45,6 +49,9 @@ val prepend_timeline_entry : t -> Journal_graph_projection.timeline_entry -> t Demand windows and recovery bookkeeping do not affect this comparison. *) val equal_presentation : t -> t -> bool +(** Equality of row/date/error content, excluding scroll commands. *) +val equal_content : t -> t -> bool + val observe_visible_range : t -> first_index:int -> last_exclusive:int -> t (** Read an index relative to the first retained slot. Out-of-range indices return [None]. *) @@ -77,7 +84,10 @@ val fail_day_request val retry_day : t -> day:int -> t val first_visible_index : t -> int val scroll_generation : t -> int64 -val scroll_target : t -> (int64 * int * string) option +val scroll_target : t -> scroll_target option + +(** Request the native top edge without changing retained content or demand. *) +val scroll_to_top : t -> t val scroll_outcome : t -> Journal_view.View.Native_list.outcome option val complete_scroll diff --git a/apple-tests/native-list/JournalTimelineReselectAcceptance.swift b/apple-tests/native-list/JournalTimelineReselectAcceptance.swift new file mode 100644 index 00000000..b2c45e5a --- /dev/null +++ b/apple-tests/native-list/JournalTimelineReselectAcceptance.swift @@ -0,0 +1,81 @@ +import XCTest + +/// Run only on the isolated scroll-top probe with synthetic long/image fixtures. +final class JournalTimelineReselectAcceptance: XCTestCase { + @MainActor private func launch(reduceMotion: Bool = false) -> XCUIApplication { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "org.logseq.journal.scroll-top-probe") + if reduceMotion { app.launchArguments = ["--reduce-motion"] } + app.launch() + XCTAssertTrue(control(app, "Journals").waitForExistence(timeout: 15)) + return app + } + + @MainActor private func control(_ app: XCUIApplication, _ label: String) -> XCUIElement { + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch + } + + @MainActor private func first(_ app: XCUIApplication) -> XCUIElement { + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Scroll row 0001")).firstMatch + } + + @MainActor private func record(_ name: String) { + let image = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + image.name = name; image.lifetime = .keepAlways; add(image) + } + + @MainActor func testLongAttachmentsAndRepeatedTopRequests() { + let app = launch() + let top = first(app) + XCTAssertTrue(top.waitForExistence(timeout: 10)) + let initialY = top.frame.minY + control(app, "Journals").tap() + XCTAssertEqual(top.frame.minY, initialY, accuracy: 2) + for _ in 0..<8 { app.collectionViews.firstMatch.swipeUp(velocity: .fast) } + record("Long list with image attachments before top") + control(app, "Journals").tap() + control(app, "Journals").tap() + control(app, "Journals").tap() + XCTAssertTrue(top.waitForExistence(timeout: 5)) + let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: top) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed) + XCTAssertEqual(top.frame.minY, initialY, accuracy: 2) + record("Repeated Timeline taps return to true top") + } + + @MainActor func testTopDuringMomentumAndNavigationRetention() { + let app = launch() + for _ in 0..<4 { app.collectionViews.firstMatch.swipeUp(velocity: .fast) } + let rows = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Scroll row")) + .allElementsBoundByIndex.filter { $0.isHittable && $0.frame.midY > 250 && $0.frame.midY < 650 } + XCTAssertFalse(rows.isEmpty) + let row = rows[0], label = row.label, y = row.frame.minY + row.tap() + XCTAssertTrue(app.navigationBars.buttons["Back"].waitForExistence(timeout: 5)) + app.navigationBars.buttons["Back"].tap() + let returned = app.staticTexts.matching(NSPredicate(format: "label == %@", label)).firstMatch + XCTAssertTrue(returned.waitForExistence(timeout: 5)) + XCTAssertEqual(returned.frame.minY, y, accuracy: 2) + control(app, "Favorites").tap() + control(app, "Journals").tap() + // Switching root destinations retains the existing navigation semantics; + // the pure owner separately verifies this switch emits no top command. + XCTAssertTrue(first(app).waitForExistence(timeout: 5)) + app.collectionViews.firstMatch.swipeUp(velocity: .fast) + control(app, "Journals").tap() + let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: first(app)) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed) + record("Momentum Timeline tap returns to top") + } + + @MainActor func testReduceMotionTop() { + // A fixture-only Reduce Motion getter override, not a system preference change. + let app = launch(reduceMotion: true) + let initialY = first(app).frame.minY + for _ in 0..<4 { app.collectionViews.firstMatch.swipeUp(velocity: .fast) } + control(app, "Journals").tap() + XCTAssertTrue(first(app).waitForExistence(timeout: 5)) + XCTAssertEqual(first(app).frame.minY, initialY, accuracy: 2) + record("Reduce Motion top") + } +} diff --git a/apple-tests/native-list/README.md b/apple-tests/native-list/README.md index 644df0e8..28d2228e 100644 --- a/apple-tests/native-list/README.md +++ b/apple-tests/native-list/README.md @@ -97,3 +97,21 @@ The final main-aligned acceptance uses main `efb97631d9f1814803abe814bd3305fc6d1 The Git-external runner also toggles an outer sign-in notice with exactly the production App.swift safeAreaInset shape. This verifies insertion/removal and native Back geometry; it does not claim to execute a real account authentication failure. That fixture-only method is not part of this repository test file. The current-main unmodified control reproduces 17 sharp top frames in 348 matching normal-content frames across two native Back operations; the coherent soft candidate has zero in 301. Final image/top/menu/rotation/preview and software-keyboard acceptance also pass. With the outer notice and no diagnostic overlay, the collection container reports isHittable=false before and after navigation while actual menu taps and a second Detail navigation from root text succeed. This container flag alone is not an acceptance criterion for those fixture controls. + + +## Repeated Timeline selection + +`JournalTimelineReselectAcceptance.swift` checks an isolated production Application +host (`org.logseq.journal.scroll-top-probe`) with 600 `Scroll row %04d` roots and +200 PNG child attachments. Drive the normal Journals/Favorites controls and native +Detail Back; keep all fixture storage and generated hosts outside this repository. +The long-list case also checks that an already-top tap preserves the first row's +position. The route case checks Detail Back retention before switching root tabs. + +The Reduce Motion case requires a fixture-only getter override activated by +`--reduce-motion`; SwiftUI's system environment value is read-only. This checks +the native no-animation branch without changing OS preferences, and must not be +reported as an actual system preference toggle. Normal acceptance uses the exact +production Swift files. Performance evidence must distinguish display-link delivery, +UIKit offsets, recorded images and actual compositor metrics; unsupported or absent +hitch measurements are unavailable, not zero. diff --git a/docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md b/docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md new file mode 100644 index 00000000..876e2d7c --- /dev/null +++ b/docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md @@ -0,0 +1,51 @@ +# Timeline Tab Scroll To Top + +## Problem + +Selecting the already active Timeline button currently returns the same state. The user wants a smooth native scroll to its true top, including the initial date heading, with no visible Simulator stutter. Favorites navigation and Detail Back retention must remain intact. + +## Proposal + +Reuse Journal Timeline's token-scoped native scroll requests and SwiftUI List/ScrollViewReader. Distinguish a top-edge request from Capture's existing row request. Root_navigation emits top requests only for repeated Journals selection on the Timeline root. Keep content, demand windows and database requests intact. Native animation follows Reduce Motion. Observe each request token so repeated taps can target the same top edge. A hidden zero-height native List anchor is the target; plain Lists use zero minimum row height so the anchor does not add blank space. Scroll-only updates send a small command to the existing mounted LUI node, retaining native rows, child identities and indices. + +Separate content equality from scroll command equality so command-only changes do not compare or notify every retained Timeline item. Preserve the mounted List, row identities and Capture's existing nonanimated row request. + +## Alternatives considered + +### Rebuild or reset the list + +Rejected: it loses navigation and retained state, widens rendering work, and does not implement scrolling. + +### Custom per-frame offset or row geometry coordination + +Rejected: native scrolling and animation are already available. No custom coordination is authorized or necessary. + +## Acceptance criteria + +- Public owner tests fail first for a repeated Timeline selection and pass after implementation; cover empty content, repeated commands, superseded completions, existing Capture commands and retained content/demand. +- Normal synthetic Simulator UI covers long lists and attachments, repeated taps, taps during momentum, already-at-top, Favorites switching and Detail Back. +- Native Reduce Motion is honored; a fixture environment override may test code behavior but is reported separately from system-setting validation. +- No real graphs, credentials, device installs, pushes or PRs; A5E and 0BA remain untouched. Coordinate GUI and Swift build windows with task-8's 1DCE owner. +- Report visual observations and available frame-time/hitch samples accurately. The user replaced strict >60fps with no obvious Simulator stutter; display-link callback frequency is not rendered FPS. + +## Risks + +- The native adapter now handles animated requests and observes pending tokens. Direct section targeting moved an already-top list by 22pt; direct UIScrollView targeting drifted under native self-sizing. Those experiments are removed. The built-in zero-height anchor passes the same physical UI regressions without geometry tracking or custom animation coordination. +- Section-edge scrolling through native ScrollViewReader needs physical layout verification, especially empty Today and attachment rows. +- The user approved the exact three-public-interface diff (Sentinel_38be01cf2ebc8191b4a7490da4fbab2a, replying to Sentinel_5ecaec6339c88191840ecb102de824e5). No Dune or spec directory edits are planned. +- Experimental host-restamped native objects remain cross_abi_validated=false. +- The host display reports 120Hz and Instruments exposes Animation Hitches, but these facts alone prove neither Simulator refresh rate nor actual rendering FPS. + +## Current validation + +- Integrated onto main 3a7a6e6004ac956322505991899f06e30d10e17c (PR87). The user-approved three-interface patch produces exactly the current .mli bytes; no Dune or spec directory edits. +- RED: empty and 1,200-row public Root_navigation cases fail because repeated selection emits no request. GREEN verifies fresh tokens, all terminal outcome fences, retained slots/demand, no item comparisons/notifications, unchanged Favorites transition and Capture token ownership. Existing Capture behavior retains its full content projection on genuine content changes. +- Final complete Dune run: 900 Alcotest cases and 87 custom PASS checks. Final source boundary check passes. Normal native UI: 2 cases, 0 failures (63.446s); first row stays at the original 164.67pt coordinate. Reduce Motion fixture: 1 case, 0 failures; forced flag true and actual system flag false. +- Normal builds contain all 30 shared production Swift files verbatim, and the native manifest verifies 318 source files plus the compiler and six borrowed dependency hashes. Locked LUI is clean at 4fdb9136026af7863a8d025133871e34864eace1. Probe objects remain cross_abi_validated=false. +- Isolated iPhone 13 Simulator 1446A493-492B-4F2D-974D-3AED9DA5C97B reports maximum refresh 60Hz. Instruments explicitly reports Hitches unsupported. XCTest returns only Scroll_DraggingAndDeceleration duration (three values: 2.599500, 2.583284, 2.566101 seconds); no hitch result is available. Debug display-link callback gaps are diagnostic and do not establish rendered FPS or rendered frame times. +- A matching Release app is built and source-verified. Final real-time manual/performance acceptance is currently blocked because Computer Use reports the Mac locked and automatic unlock failed. Manual input is paused, OS settings were not changed, and no no-visible-stutter guarantee is claimed. Resume after the user unlocks the Mac. +- Evidence, exact commands, source/native/build manifests, screenshots, movies and result bundles remain in the external task-9/evidence directory. A5E, 0BA and task-8's 1DCE were not operated on. + +## Questions + +None. The exact public-interface diff was explicitly approved by the user on 2026-10-16 with “允许”; parent delegation includes the approval reference. Implementation and synthetic testing remain authorized; publishing, phone installation and merging are outside scope. diff --git a/swift/JournalList.swift b/swift/JournalList.swift index c438bfeb..ee55c634 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -71,6 +71,11 @@ import Observation var payload: String? = nil } + private struct ScrollCommand: Decodable { + let command: String + let scroll_request: ScrollRequest? + } + struct Properties: Decodable { let style: String let sections: [SectionModel] @@ -115,6 +120,7 @@ import Observation @MainActor final class PreparedState: ObservableObject { @Published private(set) var snapshot: Snapshot? + @Published private(set) var scrollRequest: ScrollRequest? private var context: LUIAppleExtensionViewContext? private var instance = UUID() private var rowIncarnations: [String: UUID] = [:] @@ -201,12 +207,19 @@ import Observation if case let .string(json) = value { nextPayload = json } else { nextPayload = nil } if nextPayload != payload { payload = nextPayload - snapshot = nextPayload.flatMap(Snapshot.init) - let positions = snapshot?.positions ?? [:] - rowIncarnations = positions.reduce(into: [:]) { result, entry in - result[entry.key] = rowIncarnations[entry.key] ?? UUID() + if let json = nextPayload, + let command = try? JSONDecoder().decode(ScrollCommand.self, from: Data(json.utf8)), + command.command == "scroll" { + scrollRequest = command.scroll_request + } else { + snapshot = nextPayload.flatMap(Snapshot.init) + scrollRequest = snapshot?.properties.scroll_request + let positions = snapshot?.positions ?? [:] + rowIncarnations = positions.reduce(into: [:]) { result, entry in + result[entry.key] = rowIncarnations[entry.key] ?? UUID() + } + visible = visible.intersection(positions.keys) } - visible = visible.intersection(positions.keys) } dirty = false let pending = queued @@ -260,12 +273,23 @@ import Observation } } + private enum NativeAnchor: Hashable { case top } + struct View: SwiftUI.View { let context: LUIAppleExtensionViewContext @StateObject private var prepared: PreparedState @State private var handledScrollToken: Int64 = 0 @State private var scrollProxy: ScrollViewProxy? - @State private var pendingScroll: (id: String, anchor: UnitPoint)? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private struct PendingScroll { + let token: Int64 + let id: String + let anchor: UnitPoint + let animated: Bool + let trackCompletion: Bool + let topEdge: Bool + } + @State private var pendingScroll: PendingScroll? init(context: LUIAppleExtensionViewContext) { self.context = context @@ -325,15 +349,19 @@ import Observation } handledScrollToken = token guard let properties else { return } - guard let row = resolveTarget(request.target) else { - completeScroll(request.token, "missing_target") - return - } - // A row hidden by a collapsed ancestor cannot be scrolled to; the OCaml - // decoder has no hidden_target variant, so report missing_target. - guard positions[row.key] != nil else { - completeScroll(request.token, "missing_target") - return + let targetID: String + if request.target.row_path.isEmpty { + guard properties.sections.first?.key == request.target.section else { + completeScroll(token, "missing_target") + return + } + targetID = request.target.section + } else { + guard let row = resolveTarget(request.target), positions[row.key] != nil else { + completeScroll(token, "missing_target") + return + } + targetID = row.key } let anchor: UnitPoint = switch request.anchor { @@ -341,16 +369,25 @@ import Observation case "bottom": .bottom default: .top } - pendingScroll = (id: row.key, anchor: anchor) - if properties.track_scroll_completion == true { - completeScroll(request.token, "succeeded") - } + pendingScroll = PendingScroll(token: token, id: targetID, anchor: anchor, + animated: request.animated == true && !reduceMotion, + trackCompletion: properties.track_scroll_completion == true, + topEdge: request.target.row_path.isEmpty) } private func performPendingScroll() { guard let pending = pendingScroll, let proxy = scrollProxy else { return } pendingScroll = nil - proxy.scrollTo(pending.id, anchor: pending.anchor) + if pending.animated { + withAnimation(.smooth(duration: 0.45), completionCriteria: .logicallyComplete) { + proxy.scrollTo(pending.topEdge ? AnyHashable(NativeAnchor.top) : AnyHashable(pending.id), anchor: pending.anchor) + } completion: { + if pending.trackCompletion { completeScroll(pending.token, "succeeded") } + } + } else { + proxy.scrollTo(pending.topEdge ? AnyHashable(NativeAnchor.top) : AnyHashable(pending.id), anchor: pending.anchor) + if pending.trackCompletion { completeScroll(pending.token, "succeeded") } + } } private func separatorVisibility(_ name: String?) -> Visibility { @@ -455,6 +492,12 @@ import Observation var body: some SwiftUI.View { ScrollViewReader { proxy in List { + Color.clear.frame(height: 0) + .listRowInsets(EdgeInsets()) + .environment(\.defaultMinListRowHeight, 0) + .listRowSeparator(.hidden) + .accessibilityHidden(true) + .id(NativeAnchor.top) ForEach(properties?.sections ?? []) { section in Section { ForEach(section.rows) { row in @@ -477,9 +520,9 @@ import Observation prepared.bind(context) scrollProxy = proxy performPendingScroll() - if let request = properties?.scroll_request { applyScrollRequest(request) } + if let request = prepared.scrollRequest { applyScrollRequest(request) } } - .onChange(of: pendingScroll?.id) { _, _ in performPendingScroll() } + .onChange(of: pendingScroll?.token) { _, _ in performPendingScroll() } } .onDisappear { prepared.suspend() @@ -491,10 +534,10 @@ import Observation handledScrollToken = 0 pendingScroll = nil prepared.bind(context) - if let request = properties?.scroll_request { applyScrollRequest(request) } + if let request = prepared.scrollRequest { applyScrollRequest(request) } } - .onChange(of: properties?.scroll_request?.token) { _, _ in - if let request = properties?.scroll_request { applyScrollRequest(request) } + .onChange(of: prepared.scrollRequest?.token) { _, _ in + if let request = prepared.scrollRequest { applyScrollRequest(request) } } } } @@ -512,7 +555,7 @@ import Observation } else { #if os(macOS) if style == .plain { - content.listStyle(.plain) + content.listStyle(.plain).environment(\.defaultMinListRowHeight, 0) } else { // inset_grouped has no macOS equivalent — the inset style is the // closest native presentation. @@ -520,7 +563,7 @@ import Observation } #else if style == .plain { - content.listStyle(.plain) + content.listStyle(.plain).environment(\.defaultMinListRowHeight, 0) } else { content.listStyle(.insetGrouped) } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 428bb901..1feaa85f 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -385,6 +385,79 @@ let test_root_navigation_capture_lifecycle () = && Journal_routes.Favorites.items (R.favorites replaced) = []) ;; +(* Repeated destination selection is owned by the public Root_navigation reducer. + Native geometry/animation has no pure state representation and is checked separately. *) +let test_timeline_reselect ~rows () = + let module R = Application.Root_navigation in + let module T = Journal_timeline_state in + let initial = R.create ~graph_generation:3 in + let creation_time = + Journal_time.create ~instant_unix_ms:1_788_825_600_000L ~local_day:20260908 + ~local_minute_of_day:0 |> Result.get_ok + in + let block n = + Journal_model.create + ~id:(Printf.sprintf "76000000-0000-4000-8000-%012d" n) + ~page_id:"76000000-0000-4000-8000-999999999999" ~journal_day:20260908 + ~parent_id:None ~sibling_order:(Printf.sprintf "%06d" n) + ~source:"Reselect retains content" ~task_state:No_status ~child_count:0 + ~creation_time ~revision:"r" ~last_mutation_id:"76000000-0000-4000-8000-999999999998" |> Result.get_ok + in + let loaded = List.init rows block |> List.fold_left (fun state block -> + R.step state (Completed {payload=Block_captured + {block;timeline_entry_update=Some {block;child_summaries=[]}}})) initial in + let before = R.timeline loaded in + let counts = Hashtbl.create 8 in + let observe name = Hashtbl.replace counts name (1 + Option.value (Hashtbl.find_opt counts name) ~default:0) in + let store = Journal_timeline.Store.create ~observe () in + let revision = Journal_timeline.Store.synchronize store before in + Hashtbl.clear counts; + let selected = R.step loaded (Select Journals) in + let top = R.timeline selected in + Alcotest.(check bool) "reselect requests a fresh native scroll" true + (T.scroll_generation top > T.scroll_generation before && Option.is_some (T.scroll_target top)); + Alcotest.(check int) "retained content count" (T.total_count before) (T.total_count top); + for index = 0 to T.retained_slot_count before - 1 do + Alcotest.(check bool) "persistent slots retained" true + (match T.retained_slot before index, T.retained_slot top index with + | Some left, Some right -> left == right + | None, None -> true + | _ -> false) + done; + Alcotest.(check bool) "demand is retained until native visibility" true + (T.first_visible_index top = T.first_visible_index before + && T.pending_request top = T.pending_request before && T.next_request top = T.next_request before); + let next_revision = Journal_timeline.Store.synchronize store top in + Alcotest.(check int) "scroll keeps native row structure" revision next_revision; + Alcotest.(check int) "scroll does not compare each retained item" 0 + (Option.value (Hashtbl.find_opt counts "timeline-item-compare") ~default:0); + Alcotest.(check int) "scroll does not publish row content" 0 + (Option.value (Hashtbl.find_opt counts "timeline-item-notify") ~default:0); + let repeated = R.step selected (Select Journals) in + let latest = R.timeline repeated in + Alcotest.(check bool) "same destination and target get a fresh identity" true + (T.scroll_generation latest > T.scroll_generation top); + List.iter (fun outcome -> + let stale = T.complete_scroll latest ~token:(T.scroll_generation top) ~outcome in + Alcotest.(check bool) "old outcome cannot consume latest request" true (stale == latest); + let completed = T.complete_scroll stale ~token:(T.scroll_generation latest) ~outcome in + Alcotest.(check bool) "latest outcome releases command" true + (T.scroll_target completed = None && T.scroll_outcome completed = Some outcome)) + Journal_view.Event.Payload.[Succeeded;Missing_target;Cancelled;Superseded;Positioning_failed]; + let captured = R.step repeated (Completed {payload=Block_captured + {block=block (rows + 1);timeline_entry_update=Some {block=block (rows + 1);child_summaries=[]}}}) in + Alcotest.(check bool) "Capture supersedes top using the shared token sequence" true + (T.scroll_generation (R.timeline captured) > T.scroll_generation latest); + let favorite = R.step repeated (Select Favorites) in + let returned = R.step favorite (Select Journals) in + Alcotest.(check bool) "Favorites return keeps original navigation behavior" true + (R.timeline returned == latest); + let composing = R.step returned Capture_opened |> fun state -> R.step state (Select Journals) in + Alcotest.(check bool) "covered Timeline does not issue scrolling" true (R.timeline composing == latest); + let replaced = R.step returned (Graph_replaced {generation=4;graph_id=None}) in + Alcotest.(check bool) "graph lifetime retires command" true (T.scroll_target (R.timeline replaced) = None) +;; + (* Fixture teardown is outside pure reducer ownership: dispose requests an asynchronous stop on the process-wide Worker Domain. Check its public lifecycle before another fixture can attach. *) @@ -5819,7 +5892,9 @@ let () = (fun () -> test_reference_outer_terminals true) ] ) ; ( "root navigation" - , [ Alcotest.test_case + , [ Alcotest.test_case "empty Timeline reselect" `Quick (test_timeline_reselect ~rows:0) + ; Alcotest.test_case "long Timeline reselect" `Quick (test_timeline_reselect ~rows:1200) + ; Alcotest.test_case "native Favorites media visibility" `Quick test_favorites_native_visibility_retires_media diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 9f0da6bb..ef689392 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -431,7 +431,7 @@ let test_timeline_has_no_chevron () = ~key:(Ui.Key.string "test-list") ~test_id:(Ui.Test_id.string "test-list") ~rows:[ row ] - ~scroll_target:None + ~scroll_request:None ~on_scroll_completed:handler ~actions_enabled:true ~on_visible_range:handler @@ -510,7 +510,7 @@ let timeline_action_view ~enabled received = ; block_id = Some block_id } ] - ~scroll_target:None + ~scroll_request:None ~on_scroll_completed:ignore_event ~actions_enabled:enabled ~on_visible_range:ignore_event diff --git a/test/journal_timeline_state_test.ml b/test/journal_timeline_state_test.ml index 326f36ae..559b3dba 100644 --- a/test/journal_timeline_state_test.ml +++ b/test/journal_timeline_state_test.ml @@ -1368,7 +1368,7 @@ let test_capture_scroll_commands_are_explicit () = "Capture did not reset pagination visibility to the top"; require (Timeline.scroll_target captured - = Some (Timeline.scroll_generation captured, 20260809, "block:" ^ id "block" 0)) + = Some (Timeline.Row { token = Timeline.scroll_generation captured; day = 20260809; row = "block:" ^ id "block" 0 })) "Capture must target the first Journal row, not its chronologically later insertion"; let observed = Timeline.observe_visible_range captured ~first_index:0 ~last_exclusive:10 From a9c0b541a565324f3537b59b67a3368096ab4566 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sat, 17 Oct 2026 12:54:13 +0800 Subject: [PATCH 2/3] Record Release Timeline reselection acceptance --- .../JournalTimelineReselectAcceptance.swift | 4 ++-- apple-tests/native-list/README.md | 7 ++++++ .../2026-10-16-timeline-tab-scroll-to-top.md | 24 +++++++++++-------- 3 files changed, 23 insertions(+), 12 deletions(-) rename docs/agent-guide/{proposed => implemented}/feature/2026-10-16-timeline-tab-scroll-to-top.md (50%) diff --git a/apple-tests/native-list/JournalTimelineReselectAcceptance.swift b/apple-tests/native-list/JournalTimelineReselectAcceptance.swift index b2c45e5a..4b7a0281 100644 --- a/apple-tests/native-list/JournalTimelineReselectAcceptance.swift +++ b/apple-tests/native-list/JournalTimelineReselectAcceptance.swift @@ -43,7 +43,7 @@ final class JournalTimelineReselectAcceptance: XCTestCase { record("Repeated Timeline taps return to true top") } - @MainActor func testTopDuringMomentumAndNavigationRetention() { + @MainActor func testNavigationRetentionAndTopAfterScroll() { let app = launch() for _ in 0..<4 { app.collectionViews.firstMatch.swipeUp(velocity: .fast) } let rows = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Scroll row")) @@ -65,7 +65,7 @@ final class JournalTimelineReselectAcceptance: XCTestCase { control(app, "Journals").tap() let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: first(app)) XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed) - record("Momentum Timeline tap returns to top") + record("Timeline tap returns to top after navigation") } @MainActor func testReduceMotionTop() { diff --git a/apple-tests/native-list/README.md b/apple-tests/native-list/README.md index 28d2228e..70b430c8 100644 --- a/apple-tests/native-list/README.md +++ b/apple-tests/native-list/README.md @@ -115,3 +115,10 @@ reported as an actual system preference toggle. Normal acceptance uses the exact production Swift files. Performance evidence must distinguish display-link delivery, UIKit offsets, recorded images and actual compositor metrics; unsupported or absent hitch measurements are unavailable, not zero. + +XCTest waits for app quiescence before taps. The navigation test therefore covers +settled scrolling and return retention; its swipe followed by tap does not prove +a tap during active deceleration. For that boundary, record an independent normal +UI tap while the native swipe is in flight and align its timestamp with passive +`dragging`/`decelerating` samples from the same App process. Do not count a tap +after deceleration or one against a previous App process as overlap acceptance. diff --git a/docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md b/docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md similarity index 50% rename from docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md rename to docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md index 876e2d7c..9d5c8f47 100644 --- a/docs/agent-guide/proposed/feature/2026-10-16-timeline-tab-scroll-to-top.md +++ b/docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md @@ -4,7 +4,7 @@ Selecting the already active Timeline button currently returns the same state. The user wants a smooth native scroll to its true top, including the initial date heading, with no visible Simulator stutter. Favorites navigation and Detail Back retention must remain intact. -## Proposal +## Decision Reuse Journal Timeline's token-scoped native scroll requests and SwiftUI List/ScrollViewReader. Distinguish a top-edge request from Capture's existing row request. Root_navigation emits top requests only for repeated Journals selection on the Timeline root. Keep content, demand windows and database requests intact. Native animation follows Reduce Motion. Observe each request token so repeated taps can target the same top edge. A hidden zero-height native List anchor is the target; plain Lists use zero minimum row height so the anchor does not add blank space. Scroll-only updates send a small command to the existing mounted LUI node, retaining native rows, child identities and indices. @@ -28,7 +28,7 @@ Rejected: native scrolling and animation are already available. No custom coordi - No real graphs, credentials, device installs, pushes or PRs; A5E and 0BA remain untouched. Coordinate GUI and Swift build windows with task-8's 1DCE owner. - Report visual observations and available frame-time/hitch samples accurately. The user replaced strict >60fps with no obvious Simulator stutter; display-link callback frequency is not rendered FPS. -## Risks +## Consequences - The native adapter now handles animated requests and observes pending tokens. Direct section targeting moved an already-top list by 22pt; direct UIScrollView targeting drifted under native self-sizing. Those experiments are removed. The built-in zero-height anchor passes the same physical UI regressions without geometry tracking or custom animation coordination. - Section-edge scrolling through native ScrollViewReader needs physical layout verification, especially empty Today and attachment rows. @@ -38,14 +38,18 @@ Rejected: native scrolling and animation are already available. No custom coordi ## Current validation -- Integrated onto main 3a7a6e6004ac956322505991899f06e30d10e17c (PR87). The user-approved three-interface patch produces exactly the current .mli bytes; no Dune or spec directory edits. -- RED: empty and 1,200-row public Root_navigation cases fail because repeated selection emits no request. GREEN verifies fresh tokens, all terminal outcome fences, retained slots/demand, no item comparisons/notifications, unchanged Favorites transition and Capture token ownership. Existing Capture behavior retains its full content projection on genuine content changes. -- Final complete Dune run: 900 Alcotest cases and 87 custom PASS checks. Final source boundary check passes. Normal native UI: 2 cases, 0 failures (63.446s); first row stays at the original 164.67pt coordinate. Reduce Motion fixture: 1 case, 0 failures; forced flag true and actual system flag false. -- Normal builds contain all 30 shared production Swift files verbatim, and the native manifest verifies 318 source files plus the compiler and six borrowed dependency hashes. Locked LUI is clean at 4fdb9136026af7863a8d025133871e34864eace1. Probe objects remain cross_abi_validated=false. -- Isolated iPhone 13 Simulator 1446A493-492B-4F2D-974D-3AED9DA5C97B reports maximum refresh 60Hz. Instruments explicitly reports Hitches unsupported. XCTest returns only Scroll_DraggingAndDeceleration duration (three values: 2.599500, 2.583284, 2.566101 seconds); no hitch result is available. Debug display-link callback gaps are diagnostic and do not establish rendered FPS or rendered frame times. -- A matching Release app is built and source-verified. Final real-time manual/performance acceptance is currently blocked because Computer Use reports the Mac locked and automatic unlock failed. Manual input is paused, OS settings were not changed, and no no-visible-stutter guarantee is claimed. Resume after the user unlocks the Mac. -- Evidence, exact commands, source/native/build manifests, screenshots, movies and result bundles remain in the external task-9/evidence directory. A5E, 0BA and task-8's 1DCE were not operated on. +- Integrated onto main fc27cfd6 (PR87 and PR88). The approved three-interface patch still produces exactly the current .mli bytes; no Dune or spec directory edits. +- RED: empty and 1,200-row public Root_navigation cases fail because repeated selection emits no request. GREEN verifies fresh tokens, terminal outcome fences, retained slots/demand, no item comparisons/notifications, unchanged Favorites transition and Capture token ownership. Existing Capture behavior retains its full content projection on genuine content changes. +- Final complete Dune build includes @all and app/native_embed.exe.o. Full tests pass: 900 Alcotest cases and 87 custom PASS checks. Final source boundary checks pass. +- Release native UI: 2 cases, 0 failures (62.760s). The current cross-day fixture has empty Today plus yesterday's long image list; the initial first-row coordinate is 257pt and is retained after top requests. Detail Back retains the scrolled row coordinate, and Favorites switching keeps its existing navigation semantics. +- A separate normal Computer Use three-tap sequence starts while passive samples of the same native collection report decelerating=true and changing offsets (725pt immediately before the click call). The list then settles at the true top (-47pt), retaining the same collection identity. XCTest swipe-followed-by-tap alone is not treated as momentum evidence. +- Reduce Motion fixture: 1 case, 0 failures (17.060s). The exact getter override forces the no-animation branch; metadata records forcedReduceMotion=true and systemReduceMotion=false. OS preferences were not changed. This Debug fixture is separate from the normal production-Swift Release acceptance. +- Both normal hosts contain all 30 shared production Swift files verbatim. Native provenance verifies 318 source files plus the compiler and six borrowed dependency artifact hashes. Locked LUI is clean at 4fdb9136026af7863a8d025133871e34864eace1. Existing build/cache inputs were reused, without changing global pins. Probe objects remain cross_abi_validated=false and are not full iOS cross-compilation proof. +- The isolated iPhone 13 Simulator 1446A493-492B-4F2D-974D-3AED9DA5C97B reports maximum refresh 60Hz. Instruments reports Hitches unsupported; XCTest supplies no hitch values. Release Scroll_DraggingAndDeceleration durations are 2.549989, 2.583294 and 2.549919 seconds (mean 2.561s), which are gesture durations rather than frame times. +- Across the four Release performance tap windows, display-link callback medians are about 16.67ms, p95 19.35–22.75ms, and maximum gaps 163.75–505.37ms, including boundary intervals. The independent active-scroll three-tap window has median 16.67ms, p95 30.04ms and maximum 79.15ms. These are main-thread callback delivery intervals, not rendered-frame times or rendered FPS. No zero-hitch or >60FPS claim is made. +- Inspected native pictures show intermediate rows progressing to the stable complete Today top, without a blank/reset list. This is scoped visual evidence, not a general smoothness guarantee. A long recording has a decoded timestamp discontinuity, so its resampled contact sheets and average video rate cannot establish rendering cadence. Actual rendered frame times, rendered FPS and compositor hitches remain unavailable on this setup. +- Commands, source/native/build manifests, passive samples, screenshots, movies and result bundles remain in the external task-9/evidence directory. A5E, 0BA, task-8's 1DCE and the Favorites task's A135 were not operated on. GUI/build/measurement windows were coordinated and released. No phone installation or merge is part of this change. ## Questions -None. The exact public-interface diff was explicitly approved by the user on 2026-10-16 with “允许”; parent delegation includes the approval reference. Implementation and synthetic testing remain authorized; publishing, phone installation and merging are outside scope. +None. The exact public-interface diff was explicitly approved by the user on 2026-10-16 with “允许”; parent delegation includes the approval reference. The user subsequently authorized publishing a PR and an independent review session after validation. Phone installation and merging remain outside scope. From 9d99fee7c16034d4f92869b87fb7a736f45e401e Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sat, 17 Oct 2026 13:39:38 +0800 Subject: [PATCH 3/3] Fix top requests when future dates precede Today --- .../JournalTimelineReselectAcceptance.swift | 20 +++++++++++++++++-- .../2026-10-16-timeline-tab-scroll-to-top.md | 18 +++++++++++++---- swift/JournalList.swift | 5 +---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/apple-tests/native-list/JournalTimelineReselectAcceptance.swift b/apple-tests/native-list/JournalTimelineReselectAcceptance.swift index 4b7a0281..43c68afd 100644 --- a/apple-tests/native-list/JournalTimelineReselectAcceptance.swift +++ b/apple-tests/native-list/JournalTimelineReselectAcceptance.swift @@ -2,10 +2,10 @@ import XCTest /// Run only on the isolated scroll-top probe with synthetic long/image fixtures. final class JournalTimelineReselectAcceptance: XCTestCase { - @MainActor private func launch(reduceMotion: Bool = false) -> XCUIApplication { + @MainActor private func launch(reduceMotion: Bool = false, futureDates: Bool = false) -> XCUIApplication { continueAfterFailure = false let app = XCUIApplication(bundleIdentifier: "org.logseq.journal.scroll-top-probe") - if reduceMotion { app.launchArguments = ["--reduce-motion"] } + app.launchArguments = (reduceMotion ? ["--reduce-motion"] : []) + (futureDates ? ["--future-days"] : []) app.launch() XCTAssertTrue(control(app, "Journals").waitForExistence(timeout: 15)) return app @@ -68,6 +68,22 @@ final class JournalTimelineReselectAcceptance: XCTestCase { record("Timeline tap returns to top after navigation") } + @MainActor func testFutureAndTodayDatesReturnToActualTop() { + let app = launch(futureDates: true) + let future = app.staticTexts["Future-dated synthetic row"] + let today = app.staticTexts["Today synthetic row"] + XCTAssertTrue(future.waitForExistence(timeout: 10)) + XCTAssertTrue(today.waitForExistence(timeout: 10)) + let initialY = future.frame.minY + XCTAssertLessThan(initialY, today.frame.minY, "future date is the real first section") + for _ in 0..<6 { app.collectionViews.firstMatch.swipeUp(velocity: .fast) } + control(app, "Journals").tap() + let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: future) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed) + XCTAssertEqual(future.frame.minY, initialY, accuracy: 2) + record("Future date before Today returns to true top") + } + @MainActor func testReduceMotionTop() { // A fixture-only Reduce Motion getter override, not a system preference change. let app = launch(reduceMotion: true) diff --git a/docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md b/docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md index 9d5c8f47..60b1b0af 100644 --- a/docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md +++ b/docs/agent-guide/implemented/feature/2026-10-16-timeline-tab-scroll-to-top.md @@ -25,7 +25,7 @@ Rejected: native scrolling and animation are already available. No custom coordi - Public owner tests fail first for a repeated Timeline selection and pass after implementation; cover empty content, repeated commands, superseded completions, existing Capture commands and retained content/demand. - Normal synthetic Simulator UI covers long lists and attachments, repeated taps, taps during momentum, already-at-top, Favorites switching and Detail Back. - Native Reduce Motion is honored; a fixture environment override may test code behavior but is reported separately from system-setting validation. -- No real graphs, credentials, device installs, pushes or PRs; A5E and 0BA remain untouched. Coordinate GUI and Swift build windows with task-8's 1DCE owner. +- No real graphs, credentials or phone installs; A5E and 0BA remain untouched. The user later authorized PR publication and a separate independent review. Coordinate GUI/build/measurement windows with active owners. - Report visual observations and available frame-time/hitch samples accurately. The user replaced strict >60fps with no obvious Simulator stutter; display-link callback frequency is not rendered FPS. ## Consequences @@ -38,16 +38,26 @@ Rejected: native scrolling and animation are already available. No custom coordi ## Current validation +**Visual smoothness acceptance FAILED. PR 89 is not ready to satisfy the user's no-obvious-stutter requirement.** Three final Release behavior cases pass (86.890s), including the future-date regression, and the final Reduce Motion getter fixture passes (17.136s). These functional passes do not override the visible motion failure. + +Independent review found that a Top command incorrectly required Today to be the first section. A legal nonempty future+Today fixture first reproduces the native failure after a normal Journals tap (RED, 28.413s); the minimal fix targets the existing global anchor without that ordering guard (GREEN, 24.355s within the three-case run). A public pure-owner probe emits the valid Top command and retains the future first slot; only the native target guard executes the defect, so no duplicate pure regression injects an already incorrect native result. + +The final minimal-fix Release binary has SHA-256 `426e8f8f188f1b35e91a5ecdf934a21bd93d4c491907ad82c4e69de0d18b0d45`; its recorded build manifest verifies all 30 shared production Swift files and the 318 unchanged native source files. The later equatable List-content experiment did not remove visible stepping and was reverted; it is excluded from the delivered source. + +Original, unresampled frames of two new short recordings were inspected in chronological order. They show intermediate text positions holding for several frames, followed by large jumps to earlier rows and the top. The second recording made no AX-tree or screenshot requests during the animation; its major glyph-position changes occurred at video PTS 8.051667, 8.133333, 8.231667, 8.310000 and 8.343333 seconds (up to 98.334ms between these picture changes). Its callback intervals in the click window have median 16.666ms, p95 19.338ms and maximum 389.937ms. This is visible stepping evidence and callback-delivery evidence, not rendered FPS or compositor hitch measurement. A first short recording, with a post-click UI observation, has a maximum callback gap of 379.563ms; it is retained as a potentially observer-affected comparison. The earlier 505.367ms gap is confirmed inside the return operation, from click+0.153s to +0.658s, rather than at a navigation boundary. + +A separate 15-second sample of only the synthetic process shows native collection cell creation/self-sizing, SwiftUI layout and AX work during the diagnostic window. It also contains long idle portions; aggregated stacks have no per-sample timestamps and cannot identify the cause of the exact callback gap. No full-row rebuild, DB amplification, framework defect or particular causal bottleneck is asserted from this sample. Further native coordination is not implemented: AGENTS.md requires explaining the native limitation and explicit approval before custom UI coordination. Current SwiftUI ScrollViewReader+List and the reverted content-boundary experiment failed this scene; this does not prove every standard native approach is insufficient or that a custom adapter will solve it. + - Integrated onto main fc27cfd6 (PR87 and PR88). The approved three-interface patch still produces exactly the current .mli bytes; no Dune or spec directory edits. - RED: empty and 1,200-row public Root_navigation cases fail because repeated selection emits no request. GREEN verifies fresh tokens, terminal outcome fences, retained slots/demand, no item comparisons/notifications, unchanged Favorites transition and Capture token ownership. Existing Capture behavior retains its full content projection on genuine content changes. -- Final complete Dune build includes @all and app/native_embed.exe.o. Full tests pass: 900 Alcotest cases and 87 custom PASS checks. Final source boundary checks pass. +- Final complete Dune build includes @all and app/native_embed.exe.o. Final full rerun passes 900 Alcotest cases and 87 custom PASS checks, including source-boundary checks. The initial sandboxed rerun was interrupted after loopback fixture bind failed with EPERM; the complete rerun with authorized local fixture ports exits 0. - Release native UI: 2 cases, 0 failures (62.760s). The current cross-day fixture has empty Today plus yesterday's long image list; the initial first-row coordinate is 257pt and is retained after top requests. Detail Back retains the scrolled row coordinate, and Favorites switching keeps its existing navigation semantics. - A separate normal Computer Use three-tap sequence starts while passive samples of the same native collection report decelerating=true and changing offsets (725pt immediately before the click call). The list then settles at the true top (-47pt), retaining the same collection identity. XCTest swipe-followed-by-tap alone is not treated as momentum evidence. - Reduce Motion fixture: 1 case, 0 failures (17.060s). The exact getter override forces the no-animation branch; metadata records forcedReduceMotion=true and systemReduceMotion=false. OS preferences were not changed. This Debug fixture is separate from the normal production-Swift Release acceptance. -- Both normal hosts contain all 30 shared production Swift files verbatim. Native provenance verifies 318 source files plus the compiler and six borrowed dependency artifact hashes. Locked LUI is clean at 4fdb9136026af7863a8d025133871e34864eace1. Existing build/cache inputs were reused, without changing global pins. Probe objects remain cross_abi_validated=false and are not full iOS cross-compilation proof. +- The final Release host contains all 30 shared production Swift files verbatim; the earlier Debug host remains historical evidence. Native provenance verifies 318 source files plus the compiler and six borrowed dependency artifact hashes. Locked LUI is clean at 4fdb9136026af7863a8d025133871e34864eace1. Existing build/cache inputs were reused, without changing global pins. Probe objects remain cross_abi_validated=false and are not full iOS cross-compilation proof. - The isolated iPhone 13 Simulator 1446A493-492B-4F2D-974D-3AED9DA5C97B reports maximum refresh 60Hz. Instruments reports Hitches unsupported; XCTest supplies no hitch values. Release Scroll_DraggingAndDeceleration durations are 2.549989, 2.583294 and 2.549919 seconds (mean 2.561s), which are gesture durations rather than frame times. - Across the four Release performance tap windows, display-link callback medians are about 16.67ms, p95 19.35–22.75ms, and maximum gaps 163.75–505.37ms, including boundary intervals. The independent active-scroll three-tap window has median 16.67ms, p95 30.04ms and maximum 79.15ms. These are main-thread callback delivery intervals, not rendered-frame times or rendered FPS. No zero-hitch or >60FPS claim is made. -- Inspected native pictures show intermediate rows progressing to the stable complete Today top, without a blank/reset list. This is scoped visual evidence, not a general smoothness guarantee. A long recording has a decoded timestamp discontinuity, so its resampled contact sheets and average video rate cannot establish rendering cadence. Actual rendered frame times, rendered FPS and compositor hitches remain unavailable on this setup. +- Earlier native pictures show the complete top and no blank/reset list; the later short original recordings demonstrate visible stepping, so smoothness is explicitly not accepted. A long recording has a decoded timestamp discontinuity, so its resampled contact sheets and average video rate cannot establish rendering cadence. Actual rendered frame times, rendered FPS and compositor hitches remain unavailable on this setup. - Commands, source/native/build manifests, passive samples, screenshots, movies and result bundles remain in the external task-9/evidence directory. A5E, 0BA, task-8's 1DCE and the Favorites task's A135 were not operated on. GUI/build/measurement windows were coordinated and released. No phone installation or merge is part of this change. ## Questions diff --git a/swift/JournalList.swift b/swift/JournalList.swift index ee55c634..46dee6a8 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -351,10 +351,7 @@ import Observation guard let properties else { return } let targetID: String if request.target.row_path.isEmpty { - guard properties.sections.first?.key == request.target.section else { - completeScroll(token, "missing_target") - return - } + // Top is the list edge even when future dates precede Today. targetID = request.target.section } else { guard let row = resolveTarget(request.target), positions[row.key] != nil else {