From 8d98fa95b4774adb86dd34b4c0d1336bea91ba2f Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 15:34:52 +0800 Subject: [PATCH 1/5] Add Timeline tab reselect return-to-top transition --- app/application.ml | 21 +- app/journal_native_collection.ml | 5 +- app/journal_timeline_state.ml | 19 + app/journal_timeline_state.mli | 4 + .../JournalListSnapshotTests.swift | 12 +- .../feature/2026-10-23-timeline-return-top.md | 58 +++ swift/JournalList.swift | 341 +++++++++++++++--- test/application_view_test.ml | 58 ++- 8 files changed, 467 insertions(+), 51 deletions(-) create mode 100644 docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md diff --git a/app/application.ml b/app/application.ml index 37472d22..3d659a1c 100644 --- a/app/application.ml +++ b/app/application.ml @@ -373,9 +373,20 @@ let favorites_event state event = let select_destination state destination = if Journal_routes.destination state.routes = destination - then state + then ( + if destination = Journal_routes.Journals + && Journal_routes.route state.routes = Timeline + && state.modal = No_modal + && state.graph_ready + then + let timeline = Journal_timeline_state.request_top state.timeline in + if timeline == state.timeline then state else { state with timeline } + else state) else - { state with routes = Journal_routes.select_destination state.routes destination } + { state with + routes = Journal_routes.select_destination state.routes destination + ; timeline = Journal_timeline_state.cancel_scroll state.timeline + } |> fun state -> favorites_event state (Select (destination = Journal_routes.Favorites)) ;; @@ -5715,7 +5726,11 @@ let start ~request_generation:generation in with_direct_request - { snapshot with routes; next_request_generation = Int64.succ generation } + { snapshot with + routes + ; timeline = Journal_timeline_state.cancel_scroll snapshot.timeline + ; next_request_generation = Int64.succ generation + } (Journal_graph_request.Load_detail { block_id; after = None; limit = 128; request_generation = generation }) in diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index 2cdcf778..63eb66a7 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -140,14 +140,15 @@ let view let scroll_request = Option.map (fun (token, section, row) -> + let date = String.equal row ("day:" ^ section) in V.Native_list.scroll_request ~token ~target: (V.Native_list.target ~section:(Ui.Key.string section) - ~row_path:[ Ui.Key.string row ]) + ~row_path:(if date then [] else [ Ui.Key.string row ])) ~anchor:Top - ~animated:false + ~animated:date ()) scroll_target in diff --git a/app/journal_timeline_state.ml b/app/journal_timeline_state.ml index ba93a2e2..57186cb7 100644 --- a/app/journal_timeline_state.ml +++ b/app/journal_timeline_state.ml @@ -1050,6 +1050,25 @@ let next_request (state : t) = let pending_request (state : t) = state.pending +let request_top (state : t) = + match state.scroll_target with + | Some _ -> state + | None -> + let token = Int64.succ state.scroll_generation in + { state with + scroll_generation = token + ; scroll_target = Some (token, state.today, "day:" ^ string_of_int state.today) + ; scroll_outcome = None + } +;; + +let cancel_scroll (state : t) = + match state.scroll_target with + | None -> state + | Some _ -> + { state with scroll_target = None; scroll_outcome = Some Cancelled } +;; + let prepend_timeline_entry (state : t) (entry : Journal_graph_projection.timeline_entry) = let before = state in let block = entry.Journal_graph_projection.block in diff --git a/app/journal_timeline_state.mli b/app/journal_timeline_state.mli index 49df1827..bd77eb3b 100644 --- a/app/journal_timeline_state.mli +++ b/app/journal_timeline_state.mli @@ -77,6 +77,10 @@ val fail_day_request val retry_day : t -> day:int -> t val first_visible_index : t -> int val scroll_generation : t -> int64 +(** Request the first date, coalescing an outstanding native scroll. *) +val request_top : t -> t +val cancel_scroll : t -> t + val scroll_target : t -> (int64 * int * string) option val scroll_outcome : t -> Journal_view.View.Native_list.outcome option diff --git a/apple-tests/native-list/JournalListSnapshotTests.swift b/apple-tests/native-list/JournalListSnapshotTests.swift index ee093eba..a9b42cd1 100644 --- a/apple-tests/native-list/JournalListSnapshotTests.swift +++ b/apple-tests/native-list/JournalListSnapshotTests.swift @@ -82,6 +82,16 @@ import SwiftUI try backend.apply(json: String(decoding: JSONSerialization.data(withJSONObject: childOnly), as: UTF8.self)) await settle() check(owner.snapshot === first, "child-only content revision must not decode payload") + // A scroll command must not replace identical row content or force the + // native List to reprepare its cells before the jump. + try patch(backend, payload(["a", "b", "c"], token: 19)) + await settle() + check(owner.snapshot === first, "scroll-only command retains prepared row content") + check(owner.scrollRequest?.token == 19, "scroll-only command is still delivered") + try patch(backend, payload(["a", "b", "c"])) + await settle() + check(owner.snapshot === first, "scroll completion retains prepared row content") + check(owner.scrollRequest == nil, "completion clears the command independently") owner.suspend() check(!owner.receive(c, appeared: true), "offscreen retained list rejects queued callback") check(owner.snapshot === first && owner.range == 1..<2, "navigation suspension retains content and visibility") @@ -118,7 +128,7 @@ import SwiftUI check(!owner.receive(child, appeared: true), "re-expansion does not revive collapsed incarnation") try patch(backend, payload(["a", "b"], expanded: true, track: false, token: 17)) await settle() - check(owner.snapshot?.properties.track_visible_range == false && owner.snapshot?.properties.scroll_request?.token == "17", "track flags and scroll tokens invalidate snapshot") + check(owner.snapshot?.properties.track_visible_range == false && owner.scrollRequest?.token == 17, "track flags and scroll tokens invalidate snapshot") let beforeNoop = owner.snapshot try patch(backend, payload(["a", "b"], expanded: true, track: false, token: 17)) await settle() diff --git a/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md b/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md new file mode 100644 index 00000000..ab3c1df3 --- /dev/null +++ b/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md @@ -0,0 +1,58 @@ +# Timeline Return Top + +## Problem + +Selecting Timeline again currently does nothing. Users need to return to the +first journal date without animating hundreds of intermediate rows. + +## Proposal + +Reuse the existing token-scoped native list scroll request. Repeated selection +of the visible Journals root requests its first date, including empty Today. +The native List fades its content out for 90 ms, jumps without scroll animation, +then fades in for 150 ms once native scroll geometry confirms the top layout. +Keep navigation and toolbar chrome outside the transition. Coalesce pending +requests, cancel on native user interaction or disappearance, and skip fading +at the top or with Reduce Motion. Keep Capture's existing direct jump. + +## Alternatives considered + +### Animated long scroll + +Rejected because it traverses intermediate rows and can amplify layout work. + +### Replacing the list or introducing height caches + +Rejected because the current native List must retain its identity and ownership. + +## Acceptance criteria + +- Reselecting visible Timeline returns to the first date, including empty Today. +- Long lists and image rows use fade/jump/fade with stable chrome. +- Repeated taps coalesce; drag/navigation interruption cancels stale callbacks. +- Already-top and Reduce Motion do not repeat animated transitions. +- Final source passes existing tests, independent review, Simulator acceptance, + a real recording, and separate unrecorded performance sampling. + +## Risks + +- SwiftUI List layout may stall independently of animation; measure and diagnose + rather than accepting a fade as evidence of main-thread responsiveness. +- Simulator host-restamp remains experimental with cross_abi_validated=false. + +## Decision + +The user selected scheme 1 and approved the minimal existing-List UIScrollView +adapter after public SwiftUI section anchoring and top-edge requests failed the +actual content-origin check. Resolve that scroll view once per mount, perform a +native nonanimated offset-to-inset jump, and finish native layout before reveal. +Preserve List identity, delegate, datasource, row sizing and data ownership. + +Keep animation phase in the adapter so each phase does not publish a new List +model. Separate scroll commands from prepared row snapshots so command-only +changes preserve row identities. Reset interaction state on disappearance and +context replacement; the actual native pan state still gates new requests. + +The recording and formal PR are authorized; merge and TestFlight are excluded. +Remaining native List layout stalls must be disclosed with separate unrecorded +measurements. Fade completion is not proof of responsiveness. diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 2616176e..2deda7a9 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -1,6 +1,9 @@ import LUIAppleBackend import SwiftUI import Observation +#if os(iOS) +import UIKit +#endif /// SwiftUI host for the `journal-list` extension — the lui replacement for /// bonsai's `Native_list` family (grouped sections, disclosure rows, scroll @@ -20,7 +23,7 @@ import Observation /// Positions are flat indices across the displayed rows in payload order /// (disclosure children count only while the parent is expanded). @MainActor enum JournalList { - struct Action: Decodable, Identifiable { + struct Action: Decodable, Identifiable, Equatable { let key: String let enabled: Bool? let role: String? @@ -28,8 +31,8 @@ import Observation let title: String var id: String { key } } - struct ActionGroup: Decodable { let actions: [Action] } - struct Row: Decodable, Identifiable { + struct ActionGroup: Decodable, Equatable { let actions: [Action] } + struct Row: Decodable, Identifiable, Equatable { let type: String let key: String let content_index: Int @@ -41,7 +44,7 @@ import Observation var id: String { key } var isDisclosure: Bool { type == "disclosure" } } - struct SectionModel: Decodable, Identifiable { + struct SectionModel: Decodable, Identifiable, Equatable { let key: String let separator: String? let header_index: Int? @@ -71,23 +74,38 @@ import Observation var payload: String? = nil } - struct Properties: Decodable { + struct Properties: Decodable, Equatable { let style: String let sections: [SectionModel] - let scroll_request: ScrollRequest? let track_visible_range: Bool? let track_scroll_completion: Bool? } + private struct Payload: Decodable { + let content: Properties + let scrollRequest: ScrollRequest? + private enum CodingKeys: String, CodingKey { case scroll_request } + + init(from decoder: Decoder) throws { + content = try Properties(from: decoder) + let values = try decoder.container(keyedBy: CodingKeys.self) + scrollRequest = try values.decodeIfPresent(ScrollRequest.self, forKey: .scroll_request) + } + } + /// One payload observation belongs to one mounted native list. Child-model /// changes do not decode it, and cell lifecycle reads only prepared indices. @MainActor final class Snapshot { let properties: Properties let positions: [String: Int] - init?(_ json: String) { + convenience init?(_ json: String) { guard let properties = try? JSONDecoder().decode(Properties.self, from: Data(json.utf8)) else { return nil } + self.init(properties: properties) + } + + init?(properties: Properties) { var positions: [String: Int] = [:] var keys: Set = [] var valid = true @@ -115,6 +133,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] = [:] @@ -172,6 +191,7 @@ import Observation // Keep List content while a retained navigation destination is offscreen. // Binding again refreshes it before delivering any lifecycle work. payload = nil + scrollRequest = nil rowIncarnations.removeAll() visible.removeAll() queued.removeAll() @@ -200,16 +220,26 @@ import Observation let nextPayload: String? if case let .string(json) = value { nextPayload = json } else { nextPayload = nil } if nextPayload != payload { + let newBinding = payload == nil payload = nextPayload - snapshot = nextPayload.flatMap(Snapshot.init) + let decoded = nextPayload.flatMap { + try? JSONDecoder().decode(Payload.self, from: Data($0.utf8)) + } + let contentChanged = decoded?.content != snapshot?.properties + if contentChanged { + snapshot = decoded.flatMap { Snapshot(properties: $0.content) } + } + scrollRequest = snapshot == nil ? nil : decoded?.scrollRequest if snapshot == nil { JournalExtensions.report(context: context, code: "diagnostic.nativePropsDecode") } - let positions = snapshot?.positions ?? [:] - rowIncarnations = positions.reduce(into: [:]) { result, entry in - result[entry.key] = rowIncarnations[entry.key] ?? UUID() + if contentChanged || newBinding { + 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 @@ -263,12 +293,206 @@ import Observation } } + @MainActor private final class TopScroll: ObservableObject { + var active = false + var handledToken: Int64 = 0 + private var userInteracting = false + private var atTop = false + #if os(iOS) + weak var scrollView: UIScrollView? + weak var cover: UIView? + private enum Phase { case fadingOut, jumping, waitingForLayout, fadingIn } + private var phase: Phase? + private var pending: (id: UUID, token: Int64, complete: (Int64, String) -> Void)? + private var reduced = false + private var deadline: Task? + #endif + + func accept(_ token: Int64) -> Bool { + guard active, token > handledToken else { return false } + handledToken = token + cancel() + return true + } + + func interactionChanged(_ interacting: Bool) { + userInteracting = interacting + if interacting { cancel() } + } + + func geometryChanged(_ top: Bool) { + atTop = top + #if os(iOS) + if top, phase == .waitingForLayout, let id = pending?.id { reveal(id) } + #endif + } + + func cancel() { + #if os(iOS) + let previous = pending + pending = nil + phase = nil + deadline?.cancel() + deadline = nil + cover?.layer.removeAllAnimations() + UIView.performWithoutAnimation { cover?.alpha = 0 } + if let previous { previous.complete(previous.token, "cancelled") } + #endif + } + + func reduceMotionChanged(_ value: Bool) { + #if os(iOS) + reduced = value + guard value, let id = pending?.id else { return } + phase = .jumping + cover?.layer.removeAllAnimations() + UIView.performWithoutAnimation { cover?.alpha = 0 } + jump(id) + #endif + } + + #if os(iOS) + func start(token: Int64, reduced: Bool, complete: @escaping (Int64, String) -> Void) { + guard active, let scrollView, scrollView.window != nil, let cover, + !userInteracting, !scrollView.isTracking, !scrollView.isDragging else { + complete(token, "cancelled") + return + } + if scrollView.contentOffset.y + scrollView.adjustedContentInset.top <= 1 { + complete(token, "succeeded") + return + } + let id = UUID() + self.reduced = reduced + pending = (id, token, complete) + deadline = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, self?.pending?.id == id else { return } + self?.cancel() + } + if reduced { jump(id); return } + phase = .fadingOut + UIView.animate(withDuration: 0.09, delay: 0, + options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) { + cover.alpha = 1 + } completion: { [weak self] finished in + guard let self, self.pending?.id == id, self.phase == .fadingOut else { return } + if finished { self.jump(id) } else { self.cancel() } + } + } + + private func jump(_ id: UUID) { + guard active, pending?.id == id, let scrollView, scrollView.window != nil, + !scrollView.isTracking, !scrollView.isDragging else { cancel(); return } + phase = .jumping + UIView.performWithoutAnimation { + scrollView.setContentOffset( + CGPoint(x: scrollView.contentOffset.x, y: -scrollView.adjustedContentInset.top), + animated: false) + scrollView.layoutIfNeeded() + } + phase = .waitingForLayout + if atTop { reveal(id) } + } + + private func reveal(_ id: UUID) { + guard active, pending?.id == id, phase == .waitingForLayout, let cover else { return } + phase = .fadingIn + if reduced { + UIView.performWithoutAnimation { cover.alpha = 0 } + finish(id) + return + } + UIView.animate(withDuration: 0.15, delay: 0, + options: [.curveEaseIn, .allowUserInteraction, .beginFromCurrentState]) { + cover.alpha = 0 + } completion: { [weak self] finished in + guard let self, self.pending?.id == id, self.phase == .fadingIn else { return } + if finished { self.finish(id) } else { self.cancel() } + } + } + + private func finish(_ id: UUID) { + guard let previous = pending, previous.id == id else { return } + pending = nil + phase = nil + deadline?.cancel() + deadline = nil + previous.complete(previous.token, "succeeded") + } + #endif + } + + #if os(iOS) + /// Resolve the existing List scroll view once per mount; SwiftUI retains its + /// delegate, datasource, cells and sizing. The cover never intercepts touches. + private struct TopScrollAccess: UIViewRepresentable { + let owner: TopScroll + + final class Anchor: UIView { + weak var owner: TopScroll? + private var resolving = false + + override func didMoveToWindow() { + super.didMoveToWindow() + resolveIfNeeded() + } + + override func layoutSubviews() { + super.layoutSubviews() + resolveIfNeeded() + } + + func resolveIfNeeded() { + guard window != nil, owner?.scrollView?.window == nil, !resolving else { return } + resolving = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + defer { self.resolving = false } + guard self.window != nil, let owner = self.owner, + owner.scrollView?.window == nil else { return } + var ancestor = self.superview + while let candidate = ancestor, candidate !== self.window { + if let scrollView = self.findScrollView(candidate) { + owner.scrollView = scrollView + return + } + ancestor = candidate.superview + } + } + } + + private func findScrollView(_ view: UIView) -> UIScrollView? { + if let scrollView = view as? UIScrollView { return scrollView } + for child in view.subviews { + if let scrollView = findScrollView(child) { return scrollView } + } + return nil + } + } + + func makeUIView(context: Context) -> Anchor { + let anchor = Anchor() + anchor.isUserInteractionEnabled = false + anchor.accessibilityElementsHidden = true + anchor.backgroundColor = .systemBackground + anchor.alpha = 0 + anchor.owner = owner + owner.cover = anchor + return anchor + } + + func updateUIView(_ anchor: Anchor, context: Context) { anchor.resolveIfNeeded() } + } + #endif + 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)? + @State private var pendingScroll: (request: ScrollRequest, id: String, anchor: UnitPoint)? + @StateObject private var topScroll = TopScroll() + @Environment(\.accessibilityReduceMotion) private var reduceMotion init(context: LUIAppleExtensionViewContext) { self.context = context @@ -322,38 +546,50 @@ import Observation } private func applyScrollRequest(_ request: ScrollRequest) { - let token = request.token - guard token > handledScrollToken else { - return - } - handledScrollToken = token - guard let properties else { return } - guard let row = resolveTarget(request.target) else { - completeScroll(request.token, "missing_target") + guard let properties, topScroll.accept(request.token) else { return } + if request.target.row_path.isEmpty { + guard properties.sections.first?.key == request.target.section else { + completeScroll(request.token, "missing_target") + return + } + #if os(iOS) + topScroll.start(token: request.token, reduced: reduceMotion || request.animated != true) { token, outcome in + completeScroll(token, outcome) + } + #else + withoutAnimation { scrollProxy?.scrollTo(request.target.section, anchor: .top) } + completeScroll(request.token, "succeeded") + #endif 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 { + guard let row = resolveTarget(request.target), positions[row.key] != nil else { completeScroll(request.token, "missing_target") return } - let anchor: UnitPoint = - switch request.anchor { - case "center": .center - case "bottom": .bottom - default: .top - } - pendingScroll = (id: row.key, anchor: anchor) - if properties.track_scroll_completion == true { - completeScroll(request.token, "succeeded") + let anchor: UnitPoint = switch request.anchor { + case "center": .center + case "bottom": .bottom + default: .top } + pendingScroll = (request, row.key, anchor) + performPendingScroll() + } + + private func withoutAnimation(_ change: () -> Void) { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction, change) } private func performPendingScroll() { guard let pending = pendingScroll, let proxy = scrollProxy else { return } - pendingScroll = nil - proxy.scrollTo(pending.id, anchor: pending.anchor) + withoutAnimation { proxy.scrollTo(pending.id, anchor: pending.anchor) } + if !pending.request.target.row_path.isEmpty { + pendingScroll = nil + if properties?.track_scroll_completion == true { + completeScroll(pending.request.token, "succeeded") + } + } } private func separatorVisibility(_ name: String?) -> Visibility { @@ -472,33 +708,50 @@ import Observation childContent(footer) } } + .id(section.key) .listSectionSeparator(separatorVisibility(section.separator)) } } .modifier(ListStyleModifier(style: style)) + #if os(iOS) + .overlay(TopScrollAccess(owner: topScroll).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) + } .onAppear { + topScroll.active = true 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() } } .onDisappear { + topScroll.active = false + topScroll.interactionChanged(false) + topScroll.cancel() prepared.suspend() - pendingScroll = nil scrollProxy = nil + topScroll.geometryChanged(false) } .onChange(of: context.nodeID) { _, _ in + topScroll.interactionChanged(false) + topScroll.cancel() prepared.dispose() - handledScrollToken = 0 - pendingScroll = nil + topScroll.handledToken = 0 + topScroll.geometryChanged(false) 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) } + else { topScroll.cancel() } } + .onChange(of: reduceMotion) { _, reduced in topScroll.reduceMotionChanged(reduced) } } } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 14c63314..9f692f9e 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -453,6 +453,7 @@ let run_favorites_native_visibility ?calendar_change ?(timeline_rows = 3) ?(check_generation = false) + ?(check_reselect = false) ?(check_chrome = false) ?(check_error_control = false) ?malformed_native_event @@ -1488,6 +1489,59 @@ let run_favorites_native_visibility wait "Journals mounted" (fun () -> Option.is_some (find "accessibility-label" "Favorites") && Option.is_some (find "text" "Timeline fixture 2")); + if check_reselect then ( + let list () = Option.get (find "_extension" "journal-list") in + let request () = + let payload = List.assoc "payload" (Hashtbl.find props (list ())) in + Yojson.Safe.Util.member "scroll_request" (Yojson.Safe.from_string (Yojson.Safe.Util.to_string payload)) + in + let press label = dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" label))) in + let completion token outcome = + let event = Yojson.Safe.to_string (`Assoc + ["type", `String "scroll_completed"; "token", token; "outcome", `String outcome]) in + consume (hooks.extension_event (list ()) "event" + (Yojson.Safe.to_string (`Assoc ["id", `Int 1; "payload", `String event]))) + in + Alcotest.(check bool) "mount has no unsolicited scroll" true (request () = `Null); + press "Journals"; + let first = request () in + Alcotest.(check bool) "reselect requests the first date" true (first <> `Null); + Alcotest.(check bool) "section target includes empty Today" true + (Yojson.Safe.Util.(member "row_path" (member "target" first)) = `List []); + press "Journals"; + Alcotest.(check bool) "pending reselect coalesces" true (request () = first); + let token = Yojson.Safe.Util.member "token" first in + completion token "cancelled"; + Alcotest.(check bool) "drag cancellation clears request" true (request () = `Null); + press "Journals"; + let second = request () in + completion token "succeeded"; + Alcotest.(check bool) "stale completion cannot clear newer request" true (request () = second); + press "Favorites"; + press "Journals"; + Alcotest.(check bool) "returning to Timeline does not replay cancelled request" true (request () = `Null); + press "Journals"; + let third = request () in + completion (Yojson.Safe.Util.member "token" third) "succeeded"; + Alcotest.(check bool) "successful completion clears request" true (request () = `Null); + (* Root_navigation's opaque public state has no route-open event or scroll + observation. The mounted Application is the narrow public boundary. *) + press "Journals"; + let label = Option.get (find "text" "Timeline fixture 1") in + dispatch (Lui_protocol.Press (ancestor_property label "press-enabled")); + wait "Detail loads during pending return" (fun () -> + Option.is_some (find "accessibility-identifier" + ("detail-block:" ^ G.Uuid.to_string (uuid 2)))); + let nav = Option.get (find "_extension" "navigation-stack") in + let payload () = + let revision = List.assoc "revision" (Hashtbl.find props nav) + |> Yojson.Safe.Util.to_int in + Yojson.Safe.to_string (`Assoc ["revision", `Int revision; "length", `Int 0]) + in + hooks.extension_event nav "path-changed" (payload ()) |> consume; + hooks.extension_event nav "settled" (payload ()) |> consume; + (* A hidden/disabled native extension is allowed to drop its completion. *) + Alcotest.(check bool) "Detail cancels without requiring a native completion" true (request () = `Null)); (* OCaml event decoding belongs to the mounted view adapter, outside Root_navigation's public reducer events. Exercise the real decoder. *) Option.iter (fun mode -> @@ -6787,7 +6841,9 @@ let () = (fun () -> test_reference_outer_terminals true) ] ) ; ( "root navigation" - , [ Alcotest.test_case + , [ Alcotest.test_case "Timeline reselect ownership" `Quick + (fun () -> run_favorites_native_visibility ~check_reselect:true ()) + ; Alcotest.test_case "native Favorites media visibility" `Quick test_favorites_native_visibility_retires_media From 153ddf80f6c66255ec71b0f1ef7797a5494612fb Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 16:15:20 +0800 Subject: [PATCH 2/5] Separate Timeline scroll commands from native list content --- app/journal_lui_native.ml | 14 +- app/journal_lui_native.mli | 7 +- app/journal_native_collection.ml | 19 ++- app/journal_native_collection.mli | 2 + app/journal_timeline.ml | 64 +++++++- app/journal_timeline_state.ml | 5 +- app/journal_timeline_state.mli | 4 +- app/journal_view.ml | 36 +++-- app/journal_view.mli | 1 + .../JournalListSnapshotTests.swift | 39 +++-- .../feature/2026-10-23-timeline-return-top.md | 13 ++ swift/JournalExtensions.swift | 10 +- swift/JournalList.swift | 139 ++++++++++++------ test/application_view_test.ml | 12 +- test/journal_semantics_test.ml | 67 +++++++++ 15 files changed, 338 insertions(+), 94 deletions(-) diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index 136f4027..22ea913c 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -14,6 +14,7 @@ let apple_profiles = ;; let payload_property = property "payload" StringScalar true None +let scroll_request_property = property "scroll_request" StringScalar false None let event_schema = event @@ -65,7 +66,7 @@ let registry = apple_profiles true children - [ payload_property ] + [ payload_property; scroll_request_property ] [ event_schema ]); register_component registry @@ -118,9 +119,16 @@ let mount ?key ~payload ~children ?on_event identifier context parent = node ;; -let list ?key ~payload ?on_event children : Lui_elements.t = +let list ?key ~payload ?(scroll_request = "null") ?scroll_request_signal ?on_event children : Lui_elements.t = fun context parent -> - mount ?key ~payload ~children ?on_event list_identifier context parent + let node = mount ?key ~payload ~children ?on_event list_identifier context parent in + (match scroll_request_signal with + | None -> Lui_ui.extension_property context node "scroll_request" (StringValue scroll_request) + | Some source -> + let values = Signal.map (fun json -> StringValue json) source in + Signal.on_dispose context.Lui_ui.ui_scope (fun () -> Signal.dispose_signal values); + Lui_ui.extension_property_signal context node "scroll_request" values); + node ;; let image_preview ?key ~payload ?on_event children : Lui_elements.t = diff --git a/app/journal_lui_native.mli b/app/journal_lui_native.mli index 52de1c6f..7f8f795e 100644 --- a/app/journal_lui_native.mli +++ b/app/journal_lui_native.mli @@ -3,11 +3,12 @@ Replaces the [Ui.Native_widget.Extension] registrations that previously carried kinds 2103, 2104 and 2106 over the bonsai_swiftui native-widget channel, plus the native collection. - Each component ships its properties as one [payload] string field holding + Components ship their content as one [payload] string field holding the same JSON object the Swift [Properties] structs already decode, and reports events through one ["event"] extension event with [id] (int) and [payload] (JSON string) fields, matching the old - [BonsaiNativeEvent(id, payload)] contract. *) + [BonsaiNativeEvent(id, payload)] contract. Native lists carry their independent + scroll command in a separate [scroll_request] string property. *) (** Lui extension identifiers (slugs). *) val chrome_identifier : string @@ -46,6 +47,8 @@ val mount val list : ?key:string -> payload:string + -> ?scroll_request:string + -> ?scroll_request_signal:string Signal.signal -> ?on_event:(event -> unit) -> Lui_elements.t list -> Lui_elements.t diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index 63eb66a7..e52c9fa4 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -19,6 +19,7 @@ let view ~test_id ~rows ~scroll_target + ?scroll_target_signal ~on_scroll_completed ~actions_enabled ~on_visible_range @@ -27,6 +28,7 @@ let view ~on_delete ~on_copy ~children + () = let indexed = List.map (fun (row, child) -> row.slot_index, row, child) (List.combine rows children) @@ -137,7 +139,7 @@ let view })) | _ -> ()) in - let scroll_request = + let scroll_request target = Option.map (fun (token, section, row) -> let date = String.equal row ("day:" ^ section) in @@ -150,15 +152,22 @@ let view ~anchor:Top ~animated:date ()) - scroll_target + target in - V.Native_list.vertical + let list ?scroll_request_signal () = V.Native_list.vertical ~key ~style:Plain - ?scroll_request + ?scroll_request:(scroll_request scroll_target) + ?scroll_request_signal ~on_scroll_completed ~on_visible_range - sections + sections in + (match scroll_target_signal with + | None -> list () + | Some source -> V.of_lui (fun context parent -> + let requests = Signal.map scroll_request source in + Signal.on_dispose context.Lui_ui.ui_scope (fun () -> Signal.dispose_signal requests); + Ui.mount (list ~scroll_request_signal:requests ()) context parent)) |> V.Viewport.Vertical.with_test_id test_id |> V.Viewport.Vertical.overlay ~key:(Ui.Key.string "journal-empty-presentation") diff --git a/app/journal_native_collection.mli b/app/journal_native_collection.mli index 4357b0e1..e59466a2 100644 --- a/app/journal_native_collection.mli +++ b/app/journal_native_collection.mli @@ -13,6 +13,7 @@ val view -> test_id:Ui.Test_id.t -> rows:row list -> scroll_target:(int64 * string * string) option + -> ?scroll_target_signal:(int64 * string * string) option Signal.signal -> on_scroll_completed:Ui.Event.Handler.t -> actions_enabled:bool -> on_visible_range:Ui.Event.Handler.t @@ -21,4 +22,5 @@ val view -> on_delete:Ui.Event.Handler.t -> on_copy:Ui.Event.Handler.t -> children:Ui.View.t list + -> unit -> Ui.View.Viewport.Vertical.t diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml index 9ca7ee22..ff4d5cb6 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -42,9 +42,11 @@ module Store = struct type t = { rows : (string, row) Hashtbl.t ; listeners : (string, (int, listener) Hashtbl.t) Hashtbl.t + ; scroll_listeners : (int, listener) Hashtbl.t + ; mutable scroll_target : (int64 * int * string) option ; 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 @@ -53,6 +55,8 @@ module Store = struct let create ?(observe = fun _ -> ()) () = { rows = Hashtbl.create 64 ; listeners = Hashtbl.create 64 + ; scroll_listeners = Hashtbl.create 2 + ; scroll_target = None ; observe ; last = None ; shape = None @@ -64,6 +68,26 @@ module Store = struct let find t block_id = Hashtbl.find_opt t.rows block_id + let scroll_target t = t.scroll_target + + 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 publish_scroll t target = + if t.scroll_target <> target then ( + t.scroll_target <- target; + let listeners = Hashtbl.fold (fun _ value rest -> value :: rest) t.scroll_listeners [] in + List.iter (fun listener -> + if listener.active && listener.epoch = t.epoch then listener.callback ()) listeners) + ;; + let subscribe t block_id callback = let bucket = match Hashtbl.find_opt t.listeners block_id with @@ -102,6 +126,7 @@ module Store = struct (* Only a changed timeline presentation reaches this traversal. Media, navigation, draft and visible-demand writes do not scan retained rows. *) let synchronize t state = + publish_scroll t (Timeline.scroll_target state); let same = Option.fold ~none:false @@ -158,7 +183,7 @@ 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; @@ -169,6 +194,7 @@ module Store = struct ;; let reset t = + publish_scroll t None; t.epoch <- t.epoch + 1; t.last <- None; t.shape <- None; @@ -321,14 +347,25 @@ let view else presented in let rows, children = List.split presented in - Journal_native_collection.view + let scroll_target target = + Option.bind target (fun (token, day, row) -> + let section = string_of_int day in + if String.equal row ("day:" ^ section) then + (* Empty Today can be synthesized before future slots. Resolve the + top intent from the actual presentation, preserving Capture rows. *) + (match rows with + | [] -> None + | (first : Journal_native_collection.row) :: _ -> + Some (token, first.section, "day:" ^ first.section)) + else Some (token, section, row)) + in + let collection ?scroll_target_signal target = + Journal_native_collection.view ~key:(Ui.Key.string "journal-timeline-list") ~test_id:(Ui.Test_id.string "journal-timeline") ~rows - ~scroll_target: - (Option.map - (fun (token, day, row) -> token, string_of_int day, row) - (Timeline.scroll_target state)) + ~scroll_target:target + ?scroll_target_signal ~on_scroll_completed ~actions_enabled:(delete_enabled && actions_enabled) ~on_visible_range @@ -337,6 +374,19 @@ let view ~on_delete ~on_copy ~children + () + in + (match store with + | None -> collection (scroll_target (Timeline.scroll_target state)) + | Some store -> V.of_lui (fun context parent -> + let command = Signal.state context.Lui_ui.ui_scheduler + (scroll_target (Store.scroll_target store)) in + let unsubscribe = Store.subscribe_scroll store (fun () -> + Signal.set command (scroll_target (Store.scroll_target store))) in + Signal.on_dispose context.ui_scope (fun () -> + unsubscribe (); + Signal.dispose_signal (Signal.value command)); + Ui.mount (collection ~scroll_target_signal:(Signal.value command) None) context parent)) |> 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 57186cb7..997124ee 100644 --- a/app/journal_timeline_state.ml +++ b/app/journal_timeline_state.ml @@ -1203,14 +1203,13 @@ 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. *) +(* Content rendering consumes slots/date/errors. Scroll commands have an + independent mounted subscription; demand and recovery do not rebuild rows. *) let equal_presentation (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)) ;; diff --git a/app/journal_timeline_state.mli b/app/journal_timeline_state.mli index bd77eb3b..d6e22220 100644 --- a/app/journal_timeline_state.mli +++ b/app/journal_timeline_state.mli @@ -41,8 +41,8 @@ val replace_timeline_entry_page val prepend_timeline_entry : t -> Journal_graph_projection.timeline_entry -> t -(** Equality of the rendered slots, date/error presentation and scroll command. - Demand windows and recovery bookkeeping do not affect this comparison. *) +(** Equality of rendered content. Scroll commands, demand windows and recovery + bookkeeping do not affect this comparison. *) val equal_presentation : t -> t -> bool val observe_visible_range : t -> first_index:int -> last_exclusive:int -> t diff --git a/app/journal_view.ml b/app/journal_view.ml index 695fc3e8..c423ffca 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -1603,7 +1603,7 @@ module View = struct type scroll = { token : int64; target : target; anchor : string option [@default None]; animated : bool option [@default None] } [@@deriving yojson] type props = - { style : string; sections : section list; scroll_request : scroll option [@default None] + { style : string; sections : section list ; track_visible_range : bool; track_scroll_completion : bool } [@@deriving yojson] type row_event = { row : string; key : string } [@@deriving yojson] @@ -1622,7 +1622,7 @@ module View = struct ;; (* Content indexes retain the native child ownership order. *) - let build sections ~style ~scroll_request ~track_visible ~track_scroll = + let build sections ~style ~track_visible ~track_scroll = let contents = ref Rrbvec.empty in let next_content_index = ref 0 in let push element = @@ -1647,20 +1647,25 @@ module View = struct { key = section.section_key; separator = separator_name section.separator ; header_index; footer_index; rows = List.map row_wire section.rows } in - let scroll_wire (request : scroll_request) : Json.scroll = - { token = request.token - ; target = { section = request.target.section; row_path = request.target.row_path } - ; anchor = Option.map (function Top -> "top" | Center -> "center" | Bottom -> "bottom") request.anchor - ; animated = request.animated } - in let wire : Json.props = { style = style_name style; sections = List.map section_wire sections - ; scroll_request = Option.map scroll_wire scroll_request ; track_visible_range = track_visible; track_scroll_completion = track_scroll } in Json.props_to_yojson wire |> Yojson.Safe.to_string, Rrbvec.to_list !contents ;; + let encode_scroll_request = function + | None -> "null" + | Some (request : scroll_request) -> + let wire : Json.scroll = + { token = request.token + ; target = { section = request.target.section; row_path = request.target.row_path } + ; anchor = Option.map (function Top -> "top" | Center -> "center" | Bottom -> "bottom") request.anchor + ; animated = request.animated } + in + Json.scroll_to_yojson wire |> Yojson.Safe.to_string + ;; + let decode_outcome = function | "succeeded" -> Some Event.Payload.Succeeded | "missing_target" -> Some Missing_target @@ -1678,6 +1683,7 @@ module View = struct ?key ~style ?scroll_request + ?scroll_request_signal ?on_scroll_completed ?on_visible_range sections @@ -1686,7 +1692,6 @@ module View = struct build sections ~style - ~scroll_request ~track_visible:(Option.is_some on_visible_range) ~track_scroll:(Option.is_some on_scroll_completed) in @@ -1754,12 +1759,17 @@ module View = struct | _ -> () in element ?key (fun context parent -> - Journal_lui_native.mount + let requests = Option.map (fun source -> + let mapped = Signal.map encode_scroll_request source in + Signal.on_dispose context.Lui_ui.ui_scope (fun () -> Signal.dispose_signal mapped); + mapped) scroll_request_signal in + Journal_lui_native.list ?key ~payload - ~children:(List.map (fun element -> element.mount) contents) + ~scroll_request:(encode_scroll_request scroll_request) + ?scroll_request_signal:requests ~on_event - Journal_lui_native.list_identifier + (List.map (fun element -> element.mount) contents) context parent) ;; diff --git a/app/journal_view.mli b/app/journal_view.mli index 0c4569bb..91a64024 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -702,6 +702,7 @@ module View : sig : ?key:Key.t -> style:style -> ?scroll_request:scroll_request + -> ?scroll_request_signal:scroll_request option Signal.signal -> ?on_scroll_completed:Event.handler -> ?on_visible_range:Event.handler -> section list diff --git a/apple-tests/native-list/JournalListSnapshotTests.swift b/apple-tests/native-list/JournalListSnapshotTests.swift index a9b42cd1..5246e1ff 100644 --- a/apple-tests/native-list/JournalListSnapshotTests.swift +++ b/apple-tests/native-list/JournalListSnapshotTests.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import Combine @testable import LUIAppleBackend // Native payload parsing/lifecycle ownership is not reachable through an OCaml @@ -10,7 +11,7 @@ import SwiftUI if !value() { failures += 1; FileHandle.standardOutput.write(Data(("FAIL: " + message + "\n").utf8)) } } static func payload(_ keys: [String], expanded: Bool? = nil, track: Bool = true, - token: Int64? = nil, title: String = "Status") -> String { + title: String = "Status") -> String { var rows: [[String: Any]] = keys.enumerated().map { index, key in ["type": "row", "key": key, "content_index": index, "context_menu": ["actions": [["key": "status", "title": title]]]] @@ -20,15 +21,16 @@ import SwiftUI rows[0]["expanded"] = expanded rows[0]["children"] = [["type": "row", "key": "child", "content_index": 0]] } - var object: [String: Any] = ["style": "plain", "track_visible_range": track, + let object: [String: Any] = ["style": "plain", "track_visible_range": track, "track_scroll_completion": true, "sections": [["key": "day", "rows": rows]]] - if let token { object["scroll_request"] = ["token": token, - "target": ["section": "day", "row_path": [keys.first ?? "missing"]]] } return String(decoding: try! JSONSerialization.data(withJSONObject: object, options: .sortedKeys), as: UTF8.self) } - static func patch(_ backend: LUIAppleBackend, _ payload: String) throws { + static func command(_ token: Int64) -> String { + "{\"token\":\(token),\"target\":{\"section\":\"day\",\"row_path\":[\"a\"]}}" + } + static func patch(_ backend: LUIAppleBackend, _ payload: String, property: String = "payload") throws { let object: [String: Any] = ["generation": backend.generation + 1, "ops": [["op": "set-extension-prop", - "id": 1, "property": "payload", "value": payload]]] + "id": 1, "property": property, "value": payload]]] try backend.apply(json: String(decoding: JSONSerialization.data(withJSONObject: object), as: UTF8.self)) } static func settle() async { for _ in 0..<10 { await Task.yield() } } @@ -36,7 +38,8 @@ import SwiftUI var captured: LUIAppleExtensionViewContext? let registry = LUIAppleExtensionRegistry() try registry.register(LUIAppleExtension(identifier: "journal-list", fingerprint: "snapshot-test", - acceptsStandardChildren: true, properties: [.init(name: "payload", kind: .string, isRequired: true)], + acceptsStandardChildren: true, properties: [.init(name: "payload", kind: .string, isRequired: true), + .init(name: "scroll_request", kind: .string)], events: [.init(name: "event", fields: [.init(name: "id", kind: .int, isRequired: true), .init(name: "payload", kind: .string, isRequired: true)])]) { context in captured = context; return AnyView(EmptyView()) @@ -82,16 +85,25 @@ import SwiftUI try backend.apply(json: String(decoding: JSONSerialization.data(withJSONObject: childOnly), as: UTF8.self)) await settle() check(owner.snapshot === first, "child-only content revision must not decode payload") - // A scroll command must not replace identical row content or force the - // native List to reprepare its cells before the jump. - try patch(backend, payload(["a", "b", "c"], token: 19)) + // Command-only commits never publish the content owner or decode its rows. + var publications = 0 + let publication = owner.objectWillChange.sink { publications += 1 } + var commands: [Int64?] = [] + owner.onScrollRequest = { commands.append($0?.token) } + check(commands.count == 1 && commands[0] == nil, "new subscriber observes the current empty command") + commands.removeAll() + try patch(backend, command(19), property: "scroll_request") await settle() check(owner.snapshot === first, "scroll-only command retains prepared row content") check(owner.scrollRequest?.token == 19, "scroll-only command is still delivered") - try patch(backend, payload(["a", "b", "c"])) + try patch(backend, "null", property: "scroll_request") await settle() check(owner.snapshot === first, "scroll completion retains prepared row content") check(owner.scrollRequest == nil, "completion clears the command independently") + check(commands.count == 2 && commands[0] == 19 && commands[1] == nil, + "command subscriber receives request and cancellation") + check(publications == 0, "command updates and clears must not publish the content owner") + publication.cancel() owner.suspend() check(!owner.receive(c, appeared: true), "offscreen retained list rejects queued callback") check(owner.snapshot === first && owner.range == 1..<2, "navigation suspension retains content and visibility") @@ -126,11 +138,12 @@ import SwiftUI try patch(backend, payload(["a", "b"], expanded: true)) await settle() check(!owner.receive(child, appeared: true), "re-expansion does not revive collapsed incarnation") - try patch(backend, payload(["a", "b"], expanded: true, track: false, token: 17)) + try patch(backend, payload(["a", "b"], expanded: true, track: false)) + try patch(backend, command(17), property: "scroll_request") await settle() check(owner.snapshot?.properties.track_visible_range == false && owner.scrollRequest?.token == 17, "track flags and scroll tokens invalidate snapshot") let beforeNoop = owner.snapshot - try patch(backend, payload(["a", "b"], expanded: true, track: false, token: 17)) + try patch(backend, payload(["a", "b"], expanded: true, track: false)) await settle() check(owner.snapshot === beforeNoop, "identical backend payload does not replace snapshot") try patch(backend, "malformed") diff --git a/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md b/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md index ab3c1df3..d4949123 100644 --- a/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md +++ b/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md @@ -53,6 +53,19 @@ model. Separate scroll commands from prepared row snapshots so command-only changes preserve row identities. Reset interaction state on disappearance and context replacement; the actual native pan state still gates new requests. +Follow-up review requires a distinct native-list `scroll_request` property, +including matching OCaml/Swift schemas and fingerprints. Publish command changes +through a mounted subscription without changing the Timeline content revision, +serializing its rows, decoding its content or publishing the Swift content owner. +Resolve the top intent against the actual presented first section, including +future dates and synthetic empty Today. Preserve Capture's direct row target. + +The final PR111 baseline retains synchronous native layout. Removing that call +was an unsuccessful control experiment, then it was restored to avoid revealing +before cell layout completed. Its measured stall remains unresolved. Future +measurements must start at the actual native command entry before OCaml work and +Swift decoding, and include command clearance after animation completion. + The recording and formal PR are authorized; merge and TestFlight are excluded. Remaining native List layout stalls must be disclosed with separate unrecorded measurements. Fade completion is not proof of responsiveness. diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift index 2774f44a..c7149a09 100644 --- a/swift/JournalExtensions.swift +++ b/swift/JournalExtensions.swift @@ -56,6 +56,9 @@ enum JournalExtensionFingerprint { private static let payloadProperty = JournalExtensionFingerprint.Property( name: "payload", kind: "string", required: true, defaultValue: nil) + private static let scrollRequestProperty = + JournalExtensionFingerprint.Property( + name: "scroll_request", kind: "string", required: false, defaultValue: nil) private static let event = JournalExtensionFingerprint.Event( name: "event", @@ -68,7 +71,7 @@ enum JournalExtensionFingerprint { JournalExtensionFingerprint.make( identifier: identifier, profiles: profiles, standardChildren: standardChildren, children: journalChildIdentifiers, - properties: [payloadProperty], + properties: identifier == "journal-list" ? [payloadProperty, scrollRequestProperty] : [payloadProperty], events: events ? [event] : []) } @@ -154,7 +157,10 @@ enum JournalExtensionFingerprint { standardChildren: standardChildren, events: events), acceptsStandardChildren: standardChildren, childIdentifiers: journalChildIdentifiers, - properties: [.init(name: "payload", kind: .string, isRequired: true)], + properties: identifier == "journal-list" + ? [.init(name: "payload", kind: .string, isRequired: true), + .init(name: "scroll_request", kind: .string)] + : [.init(name: "payload", kind: .string, isRequired: true)], events: events ? [eventSchema] : [], viewFactory: viewFactory) } diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 2deda7a9..6134188e 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -23,7 +23,7 @@ import UIKit /// Positions are flat indices across the displayed rows in payload order /// (disclosure children count only while the parent is expanded). @MainActor enum JournalList { - struct Action: Decodable, Identifiable, Equatable { + struct Action: Decodable, Identifiable { let key: String let enabled: Bool? let role: String? @@ -31,8 +31,8 @@ import UIKit let title: String var id: String { key } } - struct ActionGroup: Decodable, Equatable { let actions: [Action] } - struct Row: Decodable, Identifiable, Equatable { + struct ActionGroup: Decodable { let actions: [Action] } + struct Row: Decodable, Identifiable { let type: String let key: String let content_index: Int @@ -44,7 +44,7 @@ import UIKit var id: String { key } var isDisclosure: Bool { type == "disclosure" } } - struct SectionModel: Decodable, Identifiable, Equatable { + struct SectionModel: Decodable, Identifiable { let key: String let separator: String? let header_index: Int? @@ -74,25 +74,13 @@ import UIKit var payload: String? = nil } - struct Properties: Decodable, Equatable { + struct Properties: Decodable { let style: String let sections: [SectionModel] let track_visible_range: Bool? let track_scroll_completion: Bool? } - private struct Payload: Decodable { - let content: Properties - let scrollRequest: ScrollRequest? - private enum CodingKeys: String, CodingKey { case scroll_request } - - init(from decoder: Decoder) throws { - content = try Properties(from: decoder) - let values = try decoder.container(keyedBy: CodingKeys.self) - scrollRequest = try values.decodeIfPresent(ScrollRequest.self, forKey: .scroll_request) - } - } - /// One payload observation belongs to one mounted native list. Child-model /// changes do not decode it, and cell lifecycle reads only prepared indices. @MainActor final class Snapshot { @@ -133,7 +121,15 @@ import UIKit @MainActor final class PreparedState: ObservableObject { @Published private(set) var snapshot: Snapshot? - @Published private(set) var scrollRequest: ScrollRequest? + private(set) var scrollRequest: ScrollRequest? + // Commands never publish this content owner. The mounted view subscribes + // directly; row content is decoded only by its separate payload observer. + var onScrollRequest: ((ScrollRequest?) -> Void)? { + didSet { + if onScrollRequest != nil { commandNeedsDelivery = true } + deliverScrollRequest() + } + } private var context: LUIAppleExtensionViewContext? private var instance = UUID() private var rowIncarnations: [String: UUID] = [:] @@ -141,6 +137,10 @@ import UIKit private var delivered: Range? private var payload: String? private var refreshTask: Task? + private var commandTask: Task? + private var commandPayload: String? + private var commandObservation = UUID() + private var commandNeedsDelivery = false private var emitTask: Task? private var dirty = false private var active = false @@ -165,15 +165,21 @@ import UIKit func bind(_ context: LUIAppleExtensionViewContext) { if let previous = self.context, previous.nodeID != context.nodeID { dispose() } active = true - guard self.context == nil else { scheduleRange(); return } + guard self.context == nil else { + refreshScrollRequest() + scheduleRange() + return + } self.context = context refresh() + refreshScrollRequest() } // A navigation destination can be retained while temporarily offscreen. // Keep its native List content, visible keys and row identities for Back. func suspend() { active = false + onScrollRequest = nil emitTask?.cancel() emitTask = nil delivered = nil @@ -186,6 +192,11 @@ import UIKit context = nil refreshTask?.cancel() refreshTask = nil + commandTask?.cancel() + commandTask = nil + commandObservation = UUID() + commandPayload = nil + commandNeedsDelivery = false emitTask?.cancel() emitTask = nil // Keep List content while a retained navigation destination is offscreen. @@ -220,32 +231,65 @@ import UIKit let nextPayload: String? if case let .string(json) = value { nextPayload = json } else { nextPayload = nil } if nextPayload != payload { - let newBinding = payload == nil payload = nextPayload - let decoded = nextPayload.flatMap { - try? JSONDecoder().decode(Payload.self, from: Data($0.utf8)) - } - let contentChanged = decoded?.content != snapshot?.properties - if contentChanged { - snapshot = decoded.flatMap { Snapshot(properties: $0.content) } - } - scrollRequest = snapshot == nil ? nil : decoded?.scrollRequest + snapshot = nextPayload.flatMap { Snapshot($0) } if snapshot == nil { JournalExtensions.report(context: context, code: "diagnostic.nativePropsDecode") } - if contentChanged || newBinding { - let positions = snapshot?.positions ?? [:] - rowIncarnations = positions.reduce(into: [:]) { result, entry in - result[entry.key] = rowIncarnations[entry.key] ?? UUID() - } - visible = visible.intersection(positions.keys) + let positions = snapshot?.positions ?? [:] + rowIncarnations = positions.reduce(into: [:]) { result, entry in + result[entry.key] = rowIncarnations[entry.key] ?? UUID() } + visible = visible.intersection(positions.keys) + commandNeedsDelivery = true } dirty = false let pending = queued queued.removeAll() for (lease, appeared) in pending { _ = receive(lease, appeared: appeared) } scheduleRange() + deliverScrollRequest() + } + + private func refreshScrollRequest() { + guard let context else { return } + let observedInstance = instance + let observation = UUID() + commandObservation = observation + let value = withObservationTracking { + context.property("scroll_request") + } onChange: { [weak self] in + MainActor.assumeIsolated { + guard let self, self.instance == observedInstance, + self.commandObservation == observation else { return } + self.commandTask?.cancel() + self.commandTask = Task { @MainActor [weak self] in + guard let self, !Task.isCancelled, self.instance == observedInstance else { return } + self.refreshScrollRequest() + } + } + } + let next: String? + if case let .string(json) = value { next = json } else { next = nil } + if next != commandPayload { + commandPayload = next + do { + scrollRequest = try next.flatMap { + try JSONDecoder().decode(ScrollRequest?.self, from: Data($0.utf8)) + } + } catch { + scrollRequest = nil + JournalExtensions.report(context: context, code: "diagnostic.nativePropsDecode") + } + commandNeedsDelivery = true + } + deliverScrollRequest() + } + + private func deliverScrollRequest() { + guard active, !dirty, commandNeedsDelivery, let onScrollRequest else { return } + commandNeedsDelivery = false + onScrollRequest(snapshot == nil ? nil : scrollRequest) } func lease(for key: String) -> RowLease? { @@ -527,6 +571,16 @@ import UIKit emit(Event(type: "scroll_completed", token: token, outcome: outcome)) } + private func observeScrollRequests() { + prepared.onScrollRequest = { request in + if let request { applyScrollRequest(request) } + else { + if pendingScroll != nil { pendingScroll = nil } + topScroll.cancel() + } + } + } + /// Walk target.row_path from the section's top-level rows; intermediate /// elements descend into disclosure children. private func resolveTarget(_ target: ScrollRequest.Target) -> Row? { @@ -572,7 +626,8 @@ import UIKit default: .top } pendingScroll = (request, row.key, anchor) - performPendingScroll() + // Capture can insert its target in this same content commit. Wait for + // SwiftUI to render the pending request with those rows before scrolling. } private func withoutAnimation(_ change: () -> Void) { @@ -582,7 +637,8 @@ import UIKit } private func performPendingScroll() { - guard let pending = pendingScroll, let proxy = scrollProxy else { return } + guard topScroll.active, let pending = pendingScroll, + pending.request.token == topScroll.handledToken, let proxy = scrollProxy else { return } withoutAnimation { proxy.scrollTo(pending.id, anchor: pending.anchor) } if !pending.request.target.row_path.isEmpty { pendingScroll = nil @@ -727,13 +783,14 @@ import UIKit prepared.bind(context) scrollProxy = proxy performPendingScroll() - if let request = prepared.scrollRequest { applyScrollRequest(request) } + observeScrollRequests() } } .onDisappear { topScroll.active = false topScroll.interactionChanged(false) topScroll.cancel() + pendingScroll = nil prepared.suspend() scrollProxy = nil topScroll.geometryChanged(false) @@ -741,17 +798,15 @@ import UIKit .onChange(of: context.nodeID) { _, _ in topScroll.interactionChanged(false) topScroll.cancel() + pendingScroll = nil prepared.dispose() topScroll.handledToken = 0 topScroll.geometryChanged(false) prepared.bind(context) - if let request = prepared.scrollRequest { applyScrollRequest(request) } - } - .onChange(of: prepared.scrollRequest?.token) { _, _ in - if let request = prepared.scrollRequest { applyScrollRequest(request) } - else { topScroll.cancel() } + observeScrollRequests() } .onChange(of: reduceMotion) { _, reduced in topScroll.reduceMotionChanged(reduced) } + .onChange(of: pendingScroll?.request.token) { _, _ in performPendingScroll() } } } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 9f692f9e..b628bd46 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1492,8 +1492,8 @@ let run_favorites_native_visibility if check_reselect then ( let list () = Option.get (find "_extension" "journal-list") in let request () = - let payload = List.assoc "payload" (Hashtbl.find props (list ())) in - Yojson.Safe.Util.member "scroll_request" (Yojson.Safe.from_string (Yojson.Safe.Util.to_string payload)) + List.assoc "scroll_request" (Hashtbl.find props (list ())) + |> Yojson.Safe.Util.to_string |> Yojson.Safe.from_string in let press label = dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" label))) in let completion token outcome = @@ -1503,6 +1503,8 @@ let run_favorites_native_visibility (Yojson.Safe.to_string (`Assoc ["id", `Int 1; "payload", `String event]))) in Alcotest.(check bool) "mount has no unsolicited scroll" true (request () = `Null); + let content = List.assoc "payload" (Hashtbl.find props (list ())) in + Hashtbl.clear regions; press "Journals"; let first = request () in Alcotest.(check bool) "reselect requests the first date" true (first <> `Null); @@ -1513,6 +1515,12 @@ let run_favorites_native_visibility let token = Yojson.Safe.Util.member "token" first in completion token "cancelled"; Alcotest.(check bool) "drag cancellation clears request" true (request () = `Null); + Alcotest.(check bool) "command cycle preserves serialized content" true + (List.assoc "payload" (Hashtbl.find props (list ())) = content); + List.iter (fun name -> + Alcotest.(check int) ("command cycle does not rebuild " ^ name) 0 + (Option.value (Hashtbl.find_opt regions name) ~default:0)) + [ "timeline"; "timeline-item-compare"; "timeline-structure-change" ]; press "Journals"; let second = request () in completion token "succeeded"; diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index f3cfe9b9..0edd12ac 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -440,6 +440,7 @@ let test_timeline_has_no_chevron () = ~on_delete:handler ~on_copy:handler ~children:[ V.text "Body" ] + () |> V.Body.Private.to_widget in with_mounted view (fun _ ops -> @@ -519,9 +520,73 @@ let timeline_action_view ~enabled received = ~on_delete:(handler "delete") ~on_copy:(handler "copy") ~children:[ V.text "Action target" ] + () |> V.Body.Private.to_widget ;; +(* The Timeline reducer cannot see the synthetic empty-Today heading inserted + by presentation. Target resolution belongs to this public mounted view. *) +let test_timeline_return_targets_presented_first_date () = + let module Timeline = Journal_timeline_state in + let today = 20260808 in + let future = 20260809 in + let entry day index : Journal_graph_projection.timeline_entry = + let block = + Journal_model.create + ~id:(Printf.sprintf "20000000-0000-4000-a000-%012d" index) + ~page_id:(Printf.sprintf "20000000-0000-4000-b000-%012d" day) + ~journal_day:day ~parent_id:None ~sibling_order:"000000000001" + ~source:"Future-day return fixture" ~task_state:Journal_model.No_status + ~child_count:0 ~creation_time ~revision:"r" ~last_mutation_id:mutation_id + |> require_ok + in + { block; child_summaries = [] } + in + let day day entries : Journal_graph_projection.day_feed = + { page = { id = Printf.sprintf "20000000-0000-4000-b000-%012d" day; day; title = string_of_int day } + ; entries; continuation = None; has_more_entries = false } + in + let handler = Ui.Event.Handler.create (fun _ -> ()) in + List.iter (fun today_nonempty -> + let feed : Journal_graph_projection.feed = + { days = [ day future [ entry future 1 ]; day today (if today_nonempty then [ entry today 2 ] else []) ] + ; slot_count = (if today_nonempty then 4 else 3); has_more_days = false } + in + let state = Timeline.empty ~today in + let state = Timeline.begin_request state ~generation:1L (Feed { before_day = None }) in + let state = Timeline.apply_feed state ~generation:1L feed in + List.iter (fun capture -> + let state = if capture then Timeline.prepend_timeline_entry state (entry today 3) + else Timeline.request_top state in + let capture_target = Timeline.scroll_target state in + let view = Journal_timeline.view ~render_source:Fun.id + ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) + ~state ~day_presentation:(fun _ -> None) ~on_visible_range:handler + ~on_scroll_completed:handler ~on_retry_day:handler ~on_open_block:handler + ~delete_enabled:true ~actions_enabled:true ~on_status:handler + ~on_delete:handler ~on_copy:handler () |> V.Body.Private.to_widget in + with_mounted view (fun _ ops -> + let node = native_list_node (ops ()) in + let property name = List.find_map (function + | Lui_protocol.SetExtensionProp (id, property, StringValue json) + when id = node && property = name -> Some (Yojson.Basic.from_string json) + | _ -> None) (ops ()) |> Option.get in + let open Yojson.Basic.Util in + let first = property "payload" |> member "sections" |> to_list |> List.hd |> member "key" in + let target = property "scroll_request" |> member "target" in + let expected_day = if today_nonempty then future else today in + if capture then ( + let _, expected_day, expected_row = Option.get capture_target in + require (member "section" target = `String (string_of_int expected_day)) "Capture date was remapped"; + require (member "row_path" target = `List [ `String expected_row ]) + "Capture lost its direct row target") + else ( + require (first = `String (string_of_int expected_day)) "future-date fixture did not expose expected first section"; + require (member "section" target = first && member "row_path" target = `List []) + "return-to-top must target the actual presented first section"))) + [ false; true ]) [ false; true ] +;; + let test_timeline_native_status_delete_callbacks () = let received = ref [] in with_mounted (timeline_action_view ~enabled:true received) (fun app ops -> @@ -3057,6 +3122,8 @@ let tests = , test_mounted_input_revisions_remain_independent ) ; ( "Timeline native Status/Delete callbacks" , test_timeline_native_status_delete_callbacks ) + ; ( "Timeline return targets presented first date" + , test_timeline_return_targets_presented_first_date ) ; ( "native action rejection" , test_native_row_actions_ignore_disabled_missing_and_malformed ) ; "nested native action owners", test_native_nested_row_actions_bind_current_owner From 6ca54750f11ab7f607788ed3aead09fd566da6ea Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 16:57:04 +0800 Subject: [PATCH 3/5] Fade Timeline content beneath fixed chrome --- swift/JournalList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 6134188e..bea940cd 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -770,7 +770,7 @@ import UIKit } .modifier(ListStyleModifier(style: style)) #if os(iOS) - .overlay(TopScrollAccess(owner: topScroll).allowsHitTesting(false).accessibilityHidden(true)) + .overlay(TopScrollAccess(owner: topScroll).ignoresSafeArea().allowsHitTesting(false).accessibilityHidden(true)) #endif .onScrollGeometryChange(for: Bool.self) { geometry in geometry.contentOffset.y + geometry.contentInsets.top <= 1 From 5b2954c58dbe1e1638ed218564ab42b0c3e5f8a2 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 17:24:13 +0800 Subject: [PATCH 4/5] Decode chrome properties once per body evaluation --- swift/JournalChrome.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index f8d385c7..c499cc10 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -231,6 +231,7 @@ struct JournalSheetDismissButton: SwiftUI.View { } var body: some SwiftUI.View { + let properties = self.properties switch properties?.mode { case .page: if let properties, context.childIDs.count == 3, From 3ed7b1ab9518a8f9449253f1d5e7d3afd3a44379 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 19:48:45 +0800 Subject: [PATCH 5/5] docs: record Timeline return-top validation boundary --- .../feature/2026-10-23-timeline-return-top.md | 214 ++++++++++++++++++ .../feature/2026-10-23-timeline-return-top.md | 71 ------ 2 files changed, 214 insertions(+), 71 deletions(-) create mode 100644 docs/agent-guide/implemented/feature/2026-10-23-timeline-return-top.md delete mode 100644 docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md diff --git a/docs/agent-guide/implemented/feature/2026-10-23-timeline-return-top.md b/docs/agent-guide/implemented/feature/2026-10-23-timeline-return-top.md new file mode 100644 index 00000000..61e95553 --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-23-timeline-return-top.md @@ -0,0 +1,214 @@ +# Timeline return to the first journal date + +## Problem + +Selecting Journals while already on Timeline needs to return to its first +presented date without animating hundreds of intermediate rows. Navigation and +bottom controls must remain stable. The original no-stall goal is **not met**. + +## Decision + +Keep the implementation measured at code revision +`5b2954c58dbe1e1638ed218564ab42b0c3e5f8a2`, based on main after PR110. On +2026-10-23 the user accepted closing this work at the documented current 60+ ms +layout boundary and requested documentation and a formal PR. Reuse PR111. This +records implementation and handoff, not a performance pass or merge approval. +Merge and TestFlight remain outside this work. + +- Reselecting visible Timeline coalesces an outstanding request and resolves top + intent against the actual first presented section: a future journal before + nonempty Today, or synthetic empty Today when that is presented first. Capture + retains its existing direct row target and waits for that target to mount. +- Commands use an independent `scroll_request` extension property across OCaml, + native schema/fingerprint and Swift. Request and clear avoid full row snapshot + serialization, decoding, deep equality and Swift content-owner publication. +- Resolve the existing UIScrollView once per mount. A noninteractive native cover + provides content-only fade-out for 90 ms, an unanimated jump to the adjusted + top inset while hidden, synchronous native layout, then fade-in for 150 ms + after top geometry is ready. Chrome stays outside this transition. These phase + durations do not promise a 240 ms total operation. +- Already-top and system Reduce Motion skip fades. Tracking/dragging takes + priority; token/mount checks, cancellation on disappearance/context replacement + and a deadline reject stale completions. Near returns use this same transition; + the optional short decelerating scroll was not adopted. +- Bind chrome properties once per body evaluation to avoid repeated decoding; + the next body still reads current properties. There is no cross-update cache. + +Preserve List identity, delegate, datasource, data ownership, row sizing/height +mechanism and locked LUI `4fdb9136026af7863a8d025133871e34864eace1`. +Both later isolated LUI candidate sources were restored to the locked baseline; +no dependency patch or pin change is shipped. No old container/cache +implementation was restored. + +## Alternatives considered + +### Animate a long scroll or use only SwiftUI top requests + +Long animation visits intermediate rows and amplifies layout work. Both attempted +public SwiftUI section/top-edge requests failed the actual content-origin check +and timed out around one second. A pinned header alone is not actual top. The +user approved the minimal UIScrollView adapter while retaining the List. + +### Remove synchronous layout or move its scheduling + +Removing `layoutIfNeeded()` still produced roughly 83–100 ms callback gaps and +geometry before visible cells were ready. The final source restores the call to +avoid revealing incomplete layout. A later dispatch or longer cover moves or +hides a pause; neither establishes input responsiveness. + +### Cache heights, simplify wrappers or replace the container + +The bounded fixed-frame, header and shared-LUI controls below did not establish +acceptable stable improvement and were not integrated. They do not prove that +all sizing/reuse strategies fail. No native collection replacement, persistent +height cache, broad List rewrite or LUI dependency update is included. + +## Consequences + +### Unresolved performance and measurement identity + +The main-thread jump still calls `scrollView.layoutIfNeeded()`, including with +Reduce Motion. The synchronous-layout +[P1 finding](https://github.com/logseq/logseq_journal/pull/111#discussion_r4357273361) +remains unresolved. Fading does not fix that stall. Evidence identifies +visible-cell preparation and SwiftUI sizing paths, not a count proving all +loaded rows are recomputed. + +Focused final-code diagnostics recorded 62.165/70.792 ms layout and +66.614/83.356 ms maximum main-thread display-callback intervals for first/repeat +far returns. Later independent controls recorded 56.183–76.221 ms layout and +66.661–83.325 ms callback intervals over four returns. Thus 60+ ms describes the +remaining measured cost, not a fixed duration or upper bound. + +All current evidence uses synthetic data on iPhone 13 / iOS 26.1 Simulator, +Swift Release optimization and OCaml Release. DEBUG is used only for in-memory +fixture-key storage in the synthetic host. Provenance remains +`experimental-host-restamp`, `cross_abi_validated=false`: no physical-device or +full iOS cross-compilation/ABI acceptance claim. + +Unrecorded timing starts at the actual native Journals press before OCaml +execution and command decoding, through decoded command clearance plus 200 ms. +True-top checks include the initial content row, not only a pinned header. Runs +have no recording, profiler or competing build. Callback intervals are not GPU +measurements and may intersect a boundary from outside it. First means first far +return after a fresh App process; OS caches were not cleared. Small sample sizes +and diagnostic-scope overhead remain limitations. + +The final uninstrumented host and existing recording correspond to code revision +`5b2954c`, not a later documentation-only PR head. Native producer +`153ddf80f6c66255ec71b0f1ef7797a5494612fb` had unchanged native inputs revalidated +for that code. Diagnostic variants retain separate source/binary identities; +they must not be presented as shipped code. + +### Retained changes and rejected local experiments + +Command separation is implemented, not a remaining hypothesis. At `153ddf80`, +five text returns reached actual top with 60.729–67.783 ms layout, +66.625–83.291 ms callback intervals and 333.927–350.828 ms press-to-clear latency. +Three multi-image returns with system Reduce Motion on recorded 60.563–67.173 ms +layout and 82.291–99.046 ms callback intervals. Hot command decoding was about +0.023–0.028 ms; initial decoding was 3.547 ms. Older timings starting after command +decoding are not equivalent total-latency comparisons. + +The final chrome binding reduced repeated property decoding from 70–76 calls +across 13–14 bodies to 13 calls across 13 bodies. Scoped decode cost fell from +about 0.674 ms to 0.254–0.359 ms. This bounded constant-factor change does not +remove the layout stall. All instrumented Journal presentation scopes together +accounted for about 0.69–0.93 ms after the change. Uninstrumented residual includes +backend, framework and diagnostic work; it is not automatically framework-only. + +All prototypes below were external local experiments and were not integrated. +Candidate sources were restored; experimental files and evidence are retained. +First/repeat values are milliseconds: + +| Experiment | Control layout | Candidate layout | Control/candidate maximum callback intervals | Outcome | +| --- | --- | --- | --- | --- | +| Fixed frames for nine measured leading items | 72.910 / 64.780 | 68.987 / 71.749 | 83.306 / 67.367 vs 83.330 / 83.320 | No demonstrated gain | +| Date-header HStack/Spacer replaced by trailing padding and a leading frame | 54.683 / 56.595 | 65.251 / 75.047 | 66.657 / 66.661 vs 83.329 / 83.324 | No demonstrated gain | +| Adjacent LUI horizontal/vertical padding combined into EdgeInsets | Four controls: 56.183–76.221 | 65.229 / 73.449 | 66.661–83.325 vs 83.287 / 83.922 | No demonstrated gain | +| Two LUI perfProbe guards changed to single-key environment presence queries | Same four controls | 48.475 / 47.661, then 52.639 / 66.686 | 66.661–83.325 vs 50.477 / 50.029, then 66.722 / 83.312 | Positive mean signal; stable gain unconfirmed | + +The environment candidate's layout mean was 53.9 ms versus 67.8 ms for four +controls: it must not be described as wholly ineffective. Reverse confirmation +overlapped control and still reached an 83.3 ms interval. It was restored under +the conservative gate. The sequence was control, environment, padding, control, +then environment confirmation: ten complete actual-top returns in total. + +Preparation was not free: fixed-frame publication was followed by a 58.207 ms +callback interval versus 16.735 ms in its control. These are observed intervals, +not exclusive cache CPU attribution. That prototype did not bypass native +preferred sizing. Its failed benefit gate stopped the full font/rotation/image/ +content/mount invalidation matrix; no cache was integrated. Header, environment +and padding experiments added no prewarming or cache work outside measurement. + +Padding expressions passed 168 size/child-proposal-set cases, 24 exact rendered +image comparisons and temporary-component state/focus changes. Proposal sets do +not prove equal call counts or order; these are not full LUI wire-node/AX +lifecycle checks. Environment checks covered unset, empty, runtime changes and +invalid UTF-8, plus an ordinary concurrent stress run. TSan crashed and ASan +stalled during initialization before entering their test bodies; neither is a +passing sanitizer result or evidence of a candidate race. No further experiment +is part of this closeout. + +### Sampled attribution and unresolved questions + +Two aligned Time Profiler windows contained 131 main-thread samples across +61.753/69.551 ms layout spans. Inclusive matches were AttributeGraph 79, +StackLayout 71 and DynamicViewList 62. Their union covered 105/131 samples, about +80%; these paths nest. Do not add the counts or convert them into exclusive +milliseconds or function-call counts. The Release LUI environment-probe path +appeared in 11 overlapping samples; actual logging remains gated by the key. +Text/font paths appeared in nine samples. No image-decode or native Auto Layout +solver path appeared in this text fixture's sample; this does not rule them out +for other data. + +Sixty-five stacks hit the 255/256-frame limit. A separate deeper stack sample +corroborated preferred sizing but perturbed wall time and is structural evidence +only. The viewport has nine principal items: Today header, empty Today, an older +date header and six blocks; the first block has Chinese/emoji text and three +child previews. Each item has multiple view nodes. Counts do not establish +excessive hierarchy, a bug, all-list updates or repeated identical proposals. +The hottest specific row and redundant same-content/same-proposal counts remain +unknown. SwiftUI Instruments explicitly reported unsupported on Simulator; that +failed capture is not usable identity/proposal evidence. + +### Functional validation, review and remaining gaps + +Existing native snapshot/owner suites, Dune runtest and behavioral negative +controls passed for the command/target implementation. Final-code Simulator +checks covered Capture's direct target (the inserted row in the normal fixture), +near/far/already-top/repeated taps, +Favorites return, future dates with empty/nonempty Today and multi-image returns. +Earlier checks of the unchanged controller covered navigation during both fades, +active-drag priority, drag/navigation/return and actual system Reduce Motion. + +An actual native pan-began event inside the original **90 ms fade-out** was not +captured: XCTest idle/preamble timing did not land the gesture in that window. +The duration was not extended and no phase was fabricated. This remains an +unpassed scenario despite other interruption coverage. A separate macOS App build +remains blocked by the unchanged baseline `listSectionSpacing` availability; +this change did not expand into that issue. + +Independent read-only reviews checked implementation, identities and bounded +experiment conclusions. This documentation-only closeout needs document/link/ +diff review and exact-head CI, not another Simulator build. Older code results +do not become measurements of the documentation commit. Repository-wide document +validation has five pre-existing format failures outside this decision; this +document is validated separately. + +### Existing recording and evidence + +Already delivered synthetic recording: `Timeline-scheme1-5b2954c-final.mp4`, +App 1.0 (1), 49.950 seconds, 1170 by 2532; Library +`libfile_a72ac63a15e4819193c426abececd94b`, version 1. SHA-256: +`d82586ca7e30c5c41c1599720e08177a305b1d44e69675013d5cb7c636c8f30f`. +This is the real Simulator App at code revision `5b2954c`, not a concept, +failed prototype or recording of this documentation update. Recording and +unrecorded performance evidence are separate. No new recording/upload is included. + +Local records retain raw traces, failed controls and source/build manifests: +`SIZING-FINAL-DELIVERY.json`, `PERFORMANCE-SCOPE.md`, `LAYOUT-DEEP-SUMMARY.json`, +`ROW-PROPOSAL-DIAGNOSTIC.json`, `TOP-HEIGHT-EXPERIMENT-RESULT.json`, +`header-performance-*-summary.json` and `lui-ab-tests/RESULT.json`. Diagnostic +identities are separate from the uninstrumented recording identity; green CI +cannot replace this performance evidence. diff --git a/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md b/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md deleted file mode 100644 index d4949123..00000000 --- a/docs/agent-guide/proposed/feature/2026-10-23-timeline-return-top.md +++ /dev/null @@ -1,71 +0,0 @@ -# Timeline Return Top - -## Problem - -Selecting Timeline again currently does nothing. Users need to return to the -first journal date without animating hundreds of intermediate rows. - -## Proposal - -Reuse the existing token-scoped native list scroll request. Repeated selection -of the visible Journals root requests its first date, including empty Today. -The native List fades its content out for 90 ms, jumps without scroll animation, -then fades in for 150 ms once native scroll geometry confirms the top layout. -Keep navigation and toolbar chrome outside the transition. Coalesce pending -requests, cancel on native user interaction or disappearance, and skip fading -at the top or with Reduce Motion. Keep Capture's existing direct jump. - -## Alternatives considered - -### Animated long scroll - -Rejected because it traverses intermediate rows and can amplify layout work. - -### Replacing the list or introducing height caches - -Rejected because the current native List must retain its identity and ownership. - -## Acceptance criteria - -- Reselecting visible Timeline returns to the first date, including empty Today. -- Long lists and image rows use fade/jump/fade with stable chrome. -- Repeated taps coalesce; drag/navigation interruption cancels stale callbacks. -- Already-top and Reduce Motion do not repeat animated transitions. -- Final source passes existing tests, independent review, Simulator acceptance, - a real recording, and separate unrecorded performance sampling. - -## Risks - -- SwiftUI List layout may stall independently of animation; measure and diagnose - rather than accepting a fade as evidence of main-thread responsiveness. -- Simulator host-restamp remains experimental with cross_abi_validated=false. - -## Decision - -The user selected scheme 1 and approved the minimal existing-List UIScrollView -adapter after public SwiftUI section anchoring and top-edge requests failed the -actual content-origin check. Resolve that scroll view once per mount, perform a -native nonanimated offset-to-inset jump, and finish native layout before reveal. -Preserve List identity, delegate, datasource, row sizing and data ownership. - -Keep animation phase in the adapter so each phase does not publish a new List -model. Separate scroll commands from prepared row snapshots so command-only -changes preserve row identities. Reset interaction state on disappearance and -context replacement; the actual native pan state still gates new requests. - -Follow-up review requires a distinct native-list `scroll_request` property, -including matching OCaml/Swift schemas and fingerprints. Publish command changes -through a mounted subscription without changing the Timeline content revision, -serializing its rows, decoding its content or publishing the Swift content owner. -Resolve the top intent against the actual presented first section, including -future dates and synthetic empty Today. Preserve Capture's direct row target. - -The final PR111 baseline retains synchronous native layout. Removing that call -was an unsuccessful control experiment, then it was restored to avoid revealing -before cell layout completed. Its measured stall remains unresolved. Future -measurements must start at the actual native command entry before OCaml work and -Swift decoding, and include command clearance after animation completion. - -The recording and formal PR are authorized; merge and TestFlight are excluded. -Remaining native List layout stalls must be disclosed with separate unrecorded -measurements. Fade completion is not proof of responsiveness.