nandi/frqpublic Fork 0
35f0e9b
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Open a conversation at its newest line

The backlog's scroll was remembered under one name for every room, so the
viewport carried where you were in the last conversation into the next one:
read back through yesterday in one channel, open another, and the second
opened in the middle of its backlog. Flutter feels it hardest, because
switching rooms there rebuilds the tree into the same shape — Flutter keeps
the State, and the offset in it, while the backlog underneath becomes
another conversation entirely.

So the key carries the room. A conversation with nothing saved under its
name opens at the end, which is what `end-ward!` already did for a viewport
it had never seen, and the jump button's answer comes right for free: a new
key has no remembered reply, so `watch-end!` says "end" on the first frame.

The Flutter half needs one more thing, since its controller outlives what it
is showing: the key that controller is serving, kept in an atom beside it.
A widget handed a new key is holding an offset from a room the reader has
left, and that offset cannot be asked whether they are at the end of this
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T08:42:36-07:00 Browse files
35f0e9b parent: 131c6db
modified common/frq/screens/chat.cljc +22 -15
@@ -907,22 +907,29 @@
907907 (defn- messages-scroll-key
908908 "What the backlog's scroll position is remembered under.
909909
910- One name in a window: `:scroll-to-bottom` is how the jump button is answered
911- there, and the position under that name is the one the reader left behind.
912-
913- The terminal backend has no `:scroll-to-bottom` — a viewport there is moved
914- by the wheel and the page keys and by nothing else — but it does open a
915- sticky viewport it has never seen at the bottom, which is the same thing
916- said differently. So a jump renames the viewport: the tick that asks the
917- window to scroll gives the terminal a name with no position saved under it,
918- and the newest line is what it opens on.
919-
920- Only on a jump, so scrolling and every message that arrives between two
921- jumps still find the position where they left it."
910+ A name per room, because one name for every conversation is a viewport
911+ that carries where you were in the last room into the next one: read back
912+ through yesterday in one channel, open another, and the second opens in
913+ the middle of its backlog rather than at the newest line. A conversation
914+ you have never opened has nothing saved under its name, and a viewport
915+ with nothing saved opens at the end — which is what opening a room should
916+ do, and now does without anyone having to ask for it.
917+
918+ The tick as well, for the terminal. That backend has no
919+ `:scroll-to-bottom` — a viewport there is moved by the wheel and the page
920+ keys and by nothing else — but it does open a sticky viewport it has never
921+ seen at the bottom, which is the same thing said differently. So a jump
922+ renames the viewport: the tick that asks the window to scroll gives the
923+ terminal a name with no position saved under it, and the newest line is
924+ what it opens on.
925+
926+ Only on a jump and on a change of room, so scrolling and every message that
927+ arrives between two jumps still find the position where they left it."
922928 []
923- (if @terminal?
924- (str "chat-messages-" @cells/jump-tick)
925- "chat-messages"))
929+ (let [room (str @cells/current)]
930+ (if @terminal?
931+ (str "chat-messages-" @cells/jump-tick "-" room)
932+ (str "chat-messages-" room))))
926933
927934 (def sidebar-width 320)
928935
@@ -907,22 +907,29 @@
907 (defn- messages-scroll-key907 (defn- messages-scroll-key
908 "What the backlog's scroll position is remembered under.908 "What the backlog's scroll position is remembered under.
909 909
910- One name in a window: `:scroll-to-bottom` is how the jump button is answered910+ A name per room, because one name for every conversation is a viewport
911- there, and the position under that name is the one the reader left behind.911+ that carries where you were in the last room into the next one: read back
912-912+ through yesterday in one channel, open another, and the second opens in
913- The terminal backend has no `:scroll-to-bottom` — a viewport there is moved913+ the middle of its backlog rather than at the newest line. A conversation
914- by the wheel and the page keys and by nothing else — but it does open a914+ you have never opened has nothing saved under its name, and a viewport
915- sticky viewport it has never seen at the bottom, which is the same thing915+ with nothing saved opens at the end — which is what opening a room should
916- said differently. So a jump renames the viewport: the tick that asks the916+ do, and now does without anyone having to ask for it.
917- window to scroll gives the terminal a name with no position saved under it,917+
918- and the newest line is what it opens on.918+ The tick as well, for the terminal. That backend has no
919-919+ `:scroll-to-bottom` — a viewport there is moved by the wheel and the page
920- Only on a jump, so scrolling and every message that arrives between two920+ keys and by nothing else — but it does open a sticky viewport it has never
921- jumps still find the position where they left it."921+ seen at the bottom, which is the same thing said differently. So a jump
922+ renames the viewport: the tick that asks the window to scroll gives the
923+ terminal a name with no position saved under it, and the newest line is
924+ what it opens on.
925+
926+ Only on a jump and on a change of room, so scrolling and every message that
927+ arrives between two jumps still find the position where they left it."
922 []928 []
923- (if @terminal?929+ (let [room (str @cells/current)]
924- (str "chat-messages-" @cells/jump-tick)930+ (if @terminal?
925- "chat-messages"))931+ (str "chat-messages-" @cells/jump-tick "-" room)
932+ (str "chat-messages-" room))))
926 933
927 (def sidebar-width 320)934 (def sidebar-width 320)
928 935
modified flutter/src/frq/hiccup.cljd +19 -4
@@ -253,8 +253,17 @@
253253
254254 After the frame, because the extent being scrolled to is the height of
255255 content that has not been laid out yet at the point this is called."
256- [k ^m/ScrollController ctrl token]
257- (let [mark (get @scroll-marks k ::fresh)
256+ [k ^m/ScrollController ctrl last-key token]
257+ (let [;; A widget handed a new key is showing something it has never
258+ ;; shown — switching rooms rebuilds the tree into the same shape, so
259+ ;; Flutter keeps the State and the offset in it while the backlog
260+ ;; underneath becomes another conversation. Whatever this key was
261+ ;; told last time, the offset the controller is holding is where the
262+ ;; reader was in the room they have just left, so the answer to "are
263+ ;; they at the end" is not in it: treat the pair as new.
264+ moved (not= k @last-key)
265+ _ (reset! last-key k)
266+ mark (if moved ::fresh (get @scroll-marks k ::fresh))
258267 ;; Not hinted here: a controller with no clients has no position, so
259268 ;; this is nil as often as not, and a non-nullable hint on the binding
260269 ;; is a cast that fails before `(nil? pos)` below ever gets to run.
@@ -997,13 +1006,19 @@
9971006 ;; two live scroll views briefly shared it and it asserted —
9981007 ;; "ScrollController attached to multiple scroll views", which took
9991008 ;; the whole screen.
1000- :managed [ctrl (m/ScrollController)]
1009+ ;;
1010+ ;; And an atom beside it for the key that controller is serving —
1011+ ;; a fact about this widget and not about the key, which is the
1012+ ;; whole point of it: see `end-ward!`. `:dispose nil` because an
1013+ ;; atom has no resource to give back.
1014+ :managed [ctrl (m/ScrollController)
1015+ last-key (atom nil) :dispose nil]
10011016 ;; `here-ward!` after `end-ward!`, so the callback that aims at a
10021017 ;; line is queued behind the one that aims at the end: the end's
10031018 ;; runs first, sees the pending jump and stands down, and then this
10041019 ;; one clears it.
10051020 :let [_ (watch-end! k ctrl on-change)
1006- _ (end-ward! k ctrl token)
1021+ _ (end-ward! k ctrl last-key token)
10071022 _ (here-ward!)]
10081023 (m/SingleChildScrollView
10091024 .controller ctrl
@@ -253,8 +253,17 @@
253 253
254 After the frame, because the extent being scrolled to is the height of254 After the frame, because the extent being scrolled to is the height of
255 content that has not been laid out yet at the point this is called."255 content that has not been laid out yet at the point this is called."
256- [k ^m/ScrollController ctrl token]256+ [k ^m/ScrollController ctrl last-key token]
257- (let [mark (get @scroll-marks k ::fresh)257+ (let [;; A widget handed a new key is showing something it has never
258+ ;; shown — switching rooms rebuilds the tree into the same shape, so
259+ ;; Flutter keeps the State and the offset in it while the backlog
260+ ;; underneath becomes another conversation. Whatever this key was
261+ ;; told last time, the offset the controller is holding is where the
262+ ;; reader was in the room they have just left, so the answer to "are
263+ ;; they at the end" is not in it: treat the pair as new.
264+ moved (not= k @last-key)
265+ _ (reset! last-key k)
266+ mark (if moved ::fresh (get @scroll-marks k ::fresh))
258 ;; Not hinted here: a controller with no clients has no position, so267 ;; Not hinted here: a controller with no clients has no position, so
259 ;; this is nil as often as not, and a non-nullable hint on the binding268 ;; this is nil as often as not, and a non-nullable hint on the binding
260 ;; is a cast that fails before `(nil? pos)` below ever gets to run.269 ;; is a cast that fails before `(nil? pos)` below ever gets to run.
@@ -997,13 +1006,19 @@
997 ;; two live scroll views briefly shared it and it asserted —1006 ;; two live scroll views briefly shared it and it asserted —
998 ;; "ScrollController attached to multiple scroll views", which took1007 ;; "ScrollController attached to multiple scroll views", which took
999 ;; the whole screen.1008 ;; the whole screen.
1000- :managed [ctrl (m/ScrollController)]1009+ ;;
1010+ ;; And an atom beside it for the key that controller is serving —
1011+ ;; a fact about this widget and not about the key, which is the
1012+ ;; whole point of it: see `end-ward!`. `:dispose nil` because an
1013+ ;; atom has no resource to give back.
1014+ :managed [ctrl (m/ScrollController)
1015+ last-key (atom nil) :dispose nil]
1001 ;; `here-ward!` after `end-ward!`, so the callback that aims at a1016 ;; `here-ward!` after `end-ward!`, so the callback that aims at a
1002 ;; line is queued behind the one that aims at the end: the end's1017 ;; line is queued behind the one that aims at the end: the end's
1003 ;; runs first, sees the pending jump and stands down, and then this1018 ;; runs first, sees the pending jump and stands down, and then this
1004 ;; one clears it.1019 ;; one clears it.
1005 :let [_ (watch-end! k ctrl on-change)1020 :let [_ (watch-end! k ctrl on-change)
1006- _ (end-ward! k ctrl token)1021+ _ (end-ward! k ctrl last-key token)
1007 _ (here-ward!)]1022 _ (here-ward!)]
1008 (m/SingleChildScrollView1023 (m/SingleChildScrollView
1009 .controller ctrl1024 .controller ctrl
modified src/frq/state.clj +6 -4
@@ -113,10 +113,12 @@
113113 (defn overview-back!
114114 "Back to the room the strip took you out of.
115115
116- The room, and not the place in it: the backlog's scroll is remembered under
117- one name for every conversation — see `messages-scroll-key` — so what comes
118- back is the room and whatever that one viewport is currently showing of it.
119- A place of your own in every room is a bigger change than this button."
116+ The room, and with it the place in it: the backlog's scroll is remembered
117+ under a name per conversation see `messages-scroll-key` so a backend
118+ that keeps a position per name lands back where the strip found you. One
119+ that only knows whether a viewport is new to it, as the Flutter side does,
120+ brings you back to the newest line instead; both beat the middle of the
121+ last room you were in, which is what one name for every room gave."
120122 []
121123 (when-let [room @overview-return]
122124 (reset! overview-return nil)
@@ -113,10 +113,12 @@
113 (defn overview-back!113 (defn overview-back!
114 "Back to the room the strip took you out of.114 "Back to the room the strip took you out of.
115 115
116- The room, and not the place in it: the backlog's scroll is remembered under116+ The room, and with it the place in it: the backlog's scroll is remembered
117- one name for every conversation — see `messages-scroll-key` — so what comes117+ under a name per conversation see `messages-scroll-key` so a backend
118- back is the room and whatever that one viewport is currently showing of it.118+ that keeps a position per name lands back where the strip found you. One
119- A place of your own in every room is a bigger change than this button."119+ that only knows whether a viewport is new to it, as the Flutter side does,
120+ brings you back to the newest line instead; both beat the middle of the
121+ last room you were in, which is what one name for every room gave."
120 []122 []
121 (when-let [room @overview-return]123 (when-let [room @overview-return]
122 (reset! overview-return nil)124 (reset! overview-return nil)