From d30e9b7d5644e8260eb6cce7982c8f86e6237641 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 07:35:50 +0000 Subject: [PATCH] fix: keep chat pinned on stream --- CHANGELOG.md | 1 + .../Sources/ClawdbotChatUI/ChatView.swift | 37 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c09f6607..4a6a80d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot - Models: inherit session model overrides in thread/topic sessions (Telegram topics, Slack/Discord threads). (#1376) - macOS: keep local auto bind loopback-first; only use tailnet when bind=tailnet. - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) +- macOS: keep chat pinned to bottom during streaming replies. (#1279) - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. - Exec: avoid defaulting to elevated mode when elevated is not allowed. diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift index d243f6a96..44399a3e6 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatView.swift @@ -12,6 +12,7 @@ public struct ClawdbotChatView: View { @State private var scrollPosition: UUID? @State private var showSessions = false @State private var hasPerformedInitialScroll = false + @State private var isPinnedToBottom = true private let showsSessionSwitcher: Bool private let style: Style private let markdownVariant: ChatMarkdownVariant @@ -87,36 +88,28 @@ public struct ClawdbotChatView: View { private var messageList: some View { ZStack { ScrollView { - #if os(macOS) - VStack(spacing: 0) { - LazyVStack(spacing: Layout.messageSpacing) { - self.messageListRows - } - - Color.clear - .frame(height: Layout.messageListPaddingBottom) - .id(self.scrollerBottomID) - } - // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. - .scrollTargetLayout() - .padding(.top, Layout.messageListPaddingTop) - .padding(.horizontal, Layout.messageListPaddingHorizontal) - #else LazyVStack(spacing: Layout.messageSpacing) { self.messageListRows Color.clear + #if os(macOS) + .frame(height: Layout.messageListPaddingBottom) + #else .frame(height: Layout.messageListPaddingBottom + 1) + #endif .id(self.scrollerBottomID) } // Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches. .scrollTargetLayout() .padding(.top, Layout.messageListPaddingTop) .padding(.horizontal, Layout.messageListPaddingHorizontal) - #endif } // Keep the scroll pinned to the bottom for new messages. .scrollPosition(id: self.$scrollPosition, anchor: .bottom) + .onChange(of: self.scrollPosition) { _, position in + guard let position else { return } + self.isPinnedToBottom = position == self.scrollerBottomID + } if self.viewModel.isLoading { ProgressView() @@ -133,18 +126,26 @@ public struct ClawdbotChatView: View { guard !isLoading, !self.hasPerformedInitialScroll else { return } self.scrollPosition = self.scrollerBottomID self.hasPerformedInitialScroll = true + self.isPinnedToBottom = true } .onChange(of: self.viewModel.sessionKey) { _, _ in self.hasPerformedInitialScroll = false + self.isPinnedToBottom = true } .onChange(of: self.viewModel.messages.count) { _, _ in - guard self.hasPerformedInitialScroll else { return } + guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } withAnimation(.snappy(duration: 0.22)) { self.scrollPosition = self.scrollerBottomID } } .onChange(of: self.viewModel.pendingRunCount) { _, _ in - guard self.hasPerformedInitialScroll else { return } + guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } + withAnimation(.snappy(duration: 0.22)) { + self.scrollPosition = self.scrollerBottomID + } + } + .onChange(of: self.viewModel.streamingAssistantText) { _, _ in + guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return } withAnimation(.snappy(duration: 0.22)) { self.scrollPosition = self.scrollerBottomID }