@@ -117,6 +117,29 @@ private final class GaryxConversationScrollStateBox {
117117 var state = GaryxConversationScrollState ( )
118118}
119119
120+ private struct GaryxMessageBubbleActions {
121+ var model : GaryxMobileModel ?
122+ var localFilePreview : @MainActor ( _ target: String , _ reportsError: Bool ) async -> GaryxWorkspaceFilePreview ?
123+ var retryFailedUserMessage : @MainActor ( _ messageId: String ) async -> Bool
124+
125+ static let empty = GaryxMessageBubbleActions (
126+ model: nil ,
127+ localFilePreview: { _, _ in nil } ,
128+ retryFailedUserMessage: { _ in false }
129+ )
130+ }
131+
132+ private struct GaryxMessageBubbleActionsKey : EnvironmentKey {
133+ static let defaultValue = GaryxMessageBubbleActions . empty
134+ }
135+
136+ private extension EnvironmentValues {
137+ var garyxMessageBubbleActions : GaryxMessageBubbleActions {
138+ get { self [ GaryxMessageBubbleActionsKey . self] }
139+ set { self [ GaryxMessageBubbleActionsKey . self] = newValue }
140+ }
141+ }
142+
120143struct GaryxConversationView : View {
121144 @EnvironmentObject private var model : GaryxMobileModel
122145 @Environment ( \. garyxSidebarDragActive) private var sidebarDragActive
@@ -246,6 +269,19 @@ struct GaryxConversationView: View {
246269 . garyxAdaptiveTopBar {
247270 GaryxConversationHeader ( )
248271 }
272+ . environment ( \. garyxMessageBubbleActions, messageBubbleActions)
273+ }
274+
275+ private var messageBubbleActions : GaryxMessageBubbleActions {
276+ GaryxMessageBubbleActions (
277+ model: model,
278+ localFilePreview: { target, reportsError in
279+ await model. localFilePreview ( target, reportsError: reportsError)
280+ } ,
281+ retryFailedUserMessage: { messageId in
282+ await model. retryFailedUserMessage ( messageId)
283+ }
284+ )
249285 }
250286
251287 private func messageScroll( proxy: ScrollViewProxy ) -> some View {
@@ -309,12 +345,10 @@ struct GaryxConversationView: View {
309345 . padding ( . top, 18 )
310346 . padding ( . bottom, 24 )
311347 . garyxVerticalScrollContentWidth ( alignment: . topLeading)
312- // A short entrance animation keyed to cheap insertion signals
313- // (message count, indicator visibility), so new bubbles and tool
314- // rows ease in instead of popping. Streaming text growth and
315- // scroll measurements never re-key it.
316- . animation ( . easeOut( duration: 0.2 ) , value: model. messages. count)
317- . animation ( . easeOut( duration: 0.2 ) , value: model. showsTailThinkingIndicator)
348+ // Do not attach a count-driven animation to the transcript
349+ // container. A send changes the message count, composer height,
350+ // spacer, and bottom anchor in the same layout pass; animating the
351+ // whole stack makes the scroll view visibly wobble.
318352
319353 Color . clear
320354 . frame ( height: conversationBottomChromeClearance)
@@ -459,10 +493,7 @@ struct GaryxConversationView: View {
459493 let identity = conversationScrollIdentity
460494 // Long transcripts re-layout while scrolling, so a single scrollTo
461495 // can land short; the later attempts converge on the true bottom.
462- let delays : [ DispatchTimeInterval ] = [
463- . milliseconds( 0 ) , . milliseconds( 16 ) , . milliseconds( 40 ) , . milliseconds( 140 ) ,
464- . milliseconds( 320 ) , . milliseconds( 650 ) , . milliseconds( 1_000 ) ,
465- ]
496+ let delays = tailScrollRetryDelays ( for: request. reason)
466497
467498 for (index, delay) in delays. enumerated ( ) {
468499 DispatchQueue . main. asyncAfter ( deadline: . now( ) + delay) {
@@ -484,6 +515,23 @@ struct GaryxConversationView: View {
484515 }
485516 }
486517
518+ private func tailScrollRetryDelays(
519+ for reason: GaryxConversationScrollState . TailScrollReason
520+ ) -> [ DispatchTimeInterval ] {
521+ switch reason {
522+ case . tailUpdate:
523+ // Ordinary tail growth during send/streaming should stay pinned,
524+ // but long retry chains make the transcript visibly wobble while
525+ // the composer and bottom spacer are also settling.
526+ return [ . milliseconds( 0 ) , . milliseconds( 40 ) , . milliseconds( 140 ) ]
527+ case . openingThread, . manual, . repair:
528+ return [
529+ . milliseconds( 0 ) , . milliseconds( 16 ) , . milliseconds( 40 ) , . milliseconds( 140 ) ,
530+ . milliseconds( 320 ) , . milliseconds( 650 ) , . milliseconds( 1_000 ) ,
531+ ]
532+ }
533+ }
534+
487535 private var conversationScrollIdentity : String {
488536 model. selectedThread? . id ?? " garyx-draft-thread "
489537 }
@@ -1573,6 +1621,15 @@ private func garyxConfiguredBot(
15731621}
15741622
15751623private extension View {
1624+ @ViewBuilder
1625+ func garyxOptionalEnvironmentObject< Object: ObservableObject > ( _ object: Object ? ) -> some View {
1626+ if let object {
1627+ environmentObject ( object)
1628+ } else {
1629+ self
1630+ }
1631+ }
1632+
15761633 /// Opens the transcript anchored to its bottom from the very first
15771634 /// layout pass and keeps the tail pinned through content growth while
15781635 /// positioned there — no post-load programmatic scroll-down. The
@@ -1767,7 +1824,7 @@ private struct GaryxSelectedThreadEmptyConversationView: View {
17671824struct GaryxMessageBubble : View {
17681825 let message : GaryxMobileMessage
17691826 @Environment ( \. colorScheme) private var colorScheme
1770- @EnvironmentObject private var model : GaryxMobileModel
1827+ @Environment ( \ . garyxMessageBubbleActions ) private var actions
17711828 @State private var retrying = false
17721829 @State private var filePreviewSheet : GaryxMessageFilePreviewSheet ?
17731830
@@ -1784,7 +1841,7 @@ struct GaryxMessageBubble: View {
17841841 GaryxFullscreenWorkspaceFilePreview ( preview: sheet. preview) {
17851842 filePreviewSheet = nil
17861843 }
1787- . environmentObject ( model)
1844+ . garyxOptionalEnvironmentObject ( actions . model)
17881845 }
17891846 }
17901847
@@ -1954,14 +2011,14 @@ struct GaryxMessageBubble: View {
19542011
19552012 private func openMessageFileLink( _ target: String ) {
19562013 Task {
1957- guard let preview = await model . localFilePreview ( target) else { return }
2014+ guard let preview = await actions . localFilePreview ( target, true ) else { return }
19582015 filePreviewSheet = GaryxMessageFilePreviewSheet ( preview: preview)
19592016 }
19602017 }
19612018
19622019 @MainActor
19632020 private func messageImageFilePreview( _ target: String ) async -> GaryxWorkspaceFilePreview ? {
1964- await model . localFilePreview ( target, reportsError : false )
2021+ await actions . localFilePreview ( target, false )
19652022 }
19662023
19672024 @ViewBuilder
@@ -1973,7 +2030,7 @@ struct GaryxMessageBubble: View {
19732030 guard !retrying else { return }
19742031 retrying = true
19752032 Task {
1976- _ = await model . retryFailedUserMessage ( message. id)
2033+ _ = await actions . retryFailedUserMessage ( message. id)
19772034 retrying = false
19782035 }
19792036 } label: {
0 commit comments