Fix chat composer scroll

This commit is contained in:
Kaede Akino committed 2026-09-18 09:07:28 +08:00
1 parent 0e89afd1e1
commit 21597fecaf
2 files changed
+156 -4

No files matched your search

+66 -4
View File
@@ -91,6 +91,71 @@ struct TextViewMarkedTextObserver: NSViewRepresentable {
}
}
/// Makes AppKit hide the TextEditor's vertical scroller while its document fits.
///
/// SwiftUI's scroll-indicator visibility does not remove the disabled scroller
/// from a macOS TextEditor when the system is configured to always show scroll
/// bars. The underlying NSScrollView already knows whether its document exceeds
/// the clip view, so leave that decision to `autohidesScrollers` instead of
/// duplicating it from the composer's approximate text-height measurement.
struct TextEditorScrollViewConfigurator: NSViewRepresentable {
final class ResolverView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
configureAfterLayout()
}
override func layout() {
super.layout()
configureScrollView()
}
func configureAfterLayout() {
Task { @MainActor [weak self] in self?.configureScrollView() }
}
private func configureScrollView() {
guard let contentView = window?.contentView, !bounds.isEmpty,
let scrollView = textEditorScrollView(in: contentView)
else { return }
guard !scrollView.autohidesScrollers || !scrollView.hasVerticalScroller else { return }
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
scrollView.tile()
}
private func textEditorScrollView(in root: NSView) -> NSScrollView? {
let targetFrame = convert(bounds, to: root)
let targetCenter = NSPoint(x: targetFrame.midX, y: targetFrame.midY)
return descendantScrollViews(in: root).first { scrollView in
guard let textView = scrollView.documentView as? NSTextView, textView.isEditable else {
return false
}
return scrollView.convert(scrollView.bounds, to: root).contains(targetCenter)
}
}
private func descendantScrollViews(in root: NSView) -> [NSScrollView] {
var result: [NSScrollView] = []
func visit(_ view: NSView) {
if let scrollView = view as? NSScrollView { result.append(scrollView) }
view.subviews.forEach(visit)
}
visit(root)
return result
}
}
func makeNSView(context: Context) -> ResolverView {
ResolverView(frame: .zero)
}
func updateNSView(_ nsView: ResolverView, context: Context) {
nsView.configureAfterLayout()
}
}
// MARK: - Context chips
/// The row of user-visible attachments sent to the LLM endpoint.
@@ -609,10 +674,6 @@ struct ComposerView: View {
TextEditor(text: draft)
.font(.body)
.scrollContentBackground(.hidden)
.scrollIndicators(
draftHeight > maximumDraftHeight ? .automatic : .hidden,
axes: .vertical
)
.focused($isFocused)
.overlay(alignment: .topLeading) {
if draft.wrappedValue.isEmpty, !hasMarkedText {
@@ -643,6 +704,7 @@ struct ComposerView: View {
paste: { isFocused && handleContextPaste() }
)
}
.background { TextEditorScrollViewConfigurator() }
.onPasteCommand(of: [.fileURL, .image, .text]) { _ in
if !handleContextPaste() { insertShortPasteIntoEditor() }
}
+90
View File
@@ -1,5 +1,7 @@
import AgentKit
import AppKit
import Foundation
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
@@ -51,6 +53,42 @@ private actor ConversationCancellationProbe {
@Suite
@MainActor
struct ChatComposerStateTests {
@Test
func textEditorScrollerAppearsOnlyWhenItsDocumentOverflows() async throws {
let state = ComposerScrollerTestState()
let hosting = NSHostingView(
rootView: ComposerScrollerTestView(state: state)
.frame(width: 320, height: 80)
)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 80),
styleMask: .borderless,
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.contentView = hosting
defer { window.close() }
let emptyScroller = try await requireComposerScroller(in: hosting)
#expect(emptyScroller.autohidesScrollers)
#expect(emptyScroller.hasVerticalScroller)
#expect(emptyScroller.verticalScroller?.isHidden == true)
#expect(emptyScroller.verticalScroller?.isEnabled == false)
state.text = Array(repeating: "A line of composer text", count: 40).joined(separator: "\n")
let overflowingScroller = try await requireComposerScroller(in: hosting) {
$0.verticalScroller?.isEnabled == true
}
#expect(overflowingScroller.autohidesScrollers)
state.text = "Short again"
let shortScroller = try await requireComposerScroller(in: hosting) {
$0.verticalScroller?.isEnabled == false
}
#expect(shortScroller.verticalScroller?.isHidden == true)
}
@Test
func largePasteThresholdUsesCharactersOrLines() {
#expect(!ContextAttachmentImporter.shouldAttachPastedText(String(repeating: "x", count: 1_999)))
@@ -2212,3 +2250,55 @@ struct ChatComposerStateTests {
}
}
}
@Observable
@MainActor
private final class ComposerScrollerTestState {
var text = ""
}
private struct ComposerScrollerTestView: View {
@Bindable var state: ComposerScrollerTestState
var body: some View {
TextEditor(text: $state.text)
.background { TextEditorScrollViewConfigurator() }
}
}
@MainActor
private func requireComposerScroller(
in root: NSView,
condition: (NSScrollView) -> Bool = { _ in true }
) async throws -> NSScrollView {
for _ in 0..<100 {
root.layoutSubtreeIfNeeded()
if let scrollView = composerScrollViews(in: root).first(where: condition),
scrollView.autohidesScrollers
{
return scrollView
}
try await Task.sleep(for: .milliseconds(10))
}
Issue.record("The configured composer scroll view did not reach the expected state")
throw ComposerScrollerTestError.expectedStateNotReached
}
private func composerScrollViews(in root: NSView) -> [NSScrollView] {
var result: [NSScrollView] = []
func visit(_ view: NSView) {
if let scrollView = view as? NSScrollView,
let textView = scrollView.documentView as? NSTextView,
textView.isEditable
{
result.append(scrollView)
}
view.subviews.forEach(visit)
}
visit(root)
return result
}
private enum ComposerScrollerTestError: Error {
case expectedStateNotReached
}