Merge nucleic/plucky-fern-dingo into dev

Chat: open existing sessions already scrolled to the bottom (no scroll
animation) via .defaultScrollAnchor(.bottom).
This commit is contained in:
2026-06-15 00:59:12 -07:00
+31 -4
View File
@@ -15,6 +15,13 @@ struct SessionDetailView: View {
@State private var summaryExpanded = false
/// The user message awaiting a "revert to here" confirmation (its canonical `seq`).
@State private var revertSeq: UInt64?
/// Set when a session is opened; the next transcript load jumps to the bottom
/// without animation so an existing chat appears already scrolled down. The
/// transcript loads asynchronously after `openSessionID` changes, so the
/// instant jump has to ride the first content arrival, not the id change.
/// Starts armed so the first navigation into a chat (which creates this view
/// fresh, with no `openSessionID` change to observe) also lands at the bottom.
@State private var pendingOpenScroll = true
@AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.returnSends.rawValue
@@ -394,9 +401,25 @@ struct SessionDetailView: View {
.padding(.top, 14)
.frame(maxWidth: .infinity)
}
// Anchor initial layout at the bottom so an opened chat renders already
// scrolled down without this the ScrollView lays out at the top first,
// producing a visible top-then-scroll flash on open.
.defaultScrollAnchor(.bottom)
.scrollContentBackground(.hidden)
.onChange(of: store.openTranscript.count) { _, _ in scrollToEnd(proxy) }
.onChange(of: store.openSessionID) { _, _ in scrollToEnd(proxy) }
.onChange(of: store.openTranscript.count) { _, _ in
// The first load after opening jumps instantly; later appends (new
// content while reading) animate as usual.
if pendingOpenScroll {
pendingOpenScroll = false
scrollToEnd(proxy, animated: false)
} else {
scrollToEnd(proxy)
}
}
// Opening a session should land already scrolled down, not scroll into
// place after the fact. The transcript loads asynchronously, so arm the
// flag here and let the first content arrival do the instant jump.
.onChange(of: store.openSessionID) { _, _ in pendingOpenScroll = true }
.onChange(of: isBusy) { _, busy in if busy { scrollToEnd(proxy) } }
}
// The visible summary floats in the top-right corner over the transcript;
@@ -415,10 +438,14 @@ struct SessionDetailView: View {
private static let workingID = "nucleic.working-indicator"
private static let bottomAnchorID = "nucleic.transcript-bottom"
private func scrollToEnd(_ proxy: ScrollViewProxy) {
private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
// Always scroll to the trailing spacer, so the resting position shows the
// last message with the full bottom padding beneath it.
withAnimation {
if animated {
withAnimation {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
} else {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
}