From 62043eab12000aa7bfcba28ff362affb46a7d565 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 9 Aug 2026 22:57:39 +1000 Subject: [PATCH 1/2] fix(swift-ios): keep composer controls above keyboard --- .../Features/Chat/FeatureComposerView.swift | 169 +++++++++++++++++- .../FeatureComposerPowerTests.swift | 117 ++++++++++++ 2 files changed, 281 insertions(+), 5 deletions(-) diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 72ea1236260..822b2b06058 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -1,8 +1,12 @@ import SwiftUI +import UIKit struct FeatureComposerView: View { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var isManuallyExpanded = false @State private var isAttachmentFlowActive = false + @State private var dockedSoftwareKeyboardOccupiesScreen = false + @State private var composerWindow: UIWindow? @State private var attachmentPreparation = FeatureAttachmentPreparationState() @State private var pathEntries: [FeatureComposerPathEntry] = [] @State private var isPathSearchLoading = false @@ -90,6 +94,14 @@ struct FeatureComposerView: View { .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 10) + .padding( + .bottom, + FeatureComposerKeyboardLayout.bottomClearance( + dynamicTypeSize: dynamicTypeSize, + softwareKeyboardIsVisible: focused.wrappedValue + && dockedSoftwareKeyboardOccupiesScreen + ) + ) .background { LinearGradient( colors: [ @@ -102,6 +114,13 @@ struct FeatureComposerView: View { ) .ignoresSafeArea() } + .background { + FeatureComposerWindowReader { window in + composerWindow = window + updateSoftwareKeyboardState(in: window) + } + .frame(width: 0, height: 0) + } .onChange(of: focused.wrappedValue) { if FeatureComposerCollapsePolicy.shouldCollapse( isFocused: focused.wrappedValue, @@ -116,6 +135,13 @@ struct FeatureComposerView: View { .task(id: pathSearchRequest) { await updatePathSearch() } + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardWillChangeFrameNotification + ) + ) { notification in + updateSoftwareKeyboardState(from: notification, in: composerWindow) + } } private var composerSurface: some View { @@ -198,14 +224,23 @@ struct FeatureComposerView: View { axis: .vertical ) .font(T3Typography.composer) - .lineLimit(1...7) + .lineLimit( + FeatureComposerKeyboardLayout.visibleLineRange( + dynamicTypeSize: dynamicTypeSize, + softwareKeyboardIsVisible: focused.wrappedValue + && dockedSoftwareKeyboardOccupiesScreen + ) + ) .focused(focused) // Return is always editing input. Sending is deliberately button-only. .submitLabel(.return) .padding(.horizontal, 16) - .padding(.top, 14) - .padding(.bottom, 7) - .frame(minHeight: 62, alignment: .top) + .padding(.top, usesAccessibilityKeyboardMetrics ? 6 : 14) + .padding(.bottom, usesAccessibilityKeyboardMetrics ? 2 : 7) + .frame( + minHeight: usesAccessibilityKeyboardMetrics ? 44 : 62, + alignment: .top + ) if !attachments.isEmpty, !imagesAllowed { Label("Choose a model that accepts images", systemImage: "exclamationmark.circle") @@ -227,6 +262,8 @@ struct FeatureComposerView: View { } composerFooter + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) } } @@ -260,7 +297,7 @@ struct FeatureComposerView: View { } .padding(.horizontal, 7) .padding(.top, 2) - .padding(.bottom, 8) + .padding(.bottom, usesAccessibilityKeyboardMetrics ? 2 : 8) } private var submitButton: some View { @@ -446,6 +483,128 @@ struct FeatureComposerView: View { onSend() } } + + private func updateSoftwareKeyboardState(in window: UIWindow?) { + dockedSoftwareKeyboardOccupiesScreen = + FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: window?.keyboardLayoutGuide.layoutFrame, + screenBounds: window?.bounds, + isLocal: true, + sceneIsActive: window?.windowScene?.activationState == .foregroundActive + ) + } + + private func updateSoftwareKeyboardState( + from notification: Notification, + in window: UIWindow? + ) { + let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let keyboardFrame: CGRect? = window.flatMap { window in + guard let frame else { return nil } + return window.convert(frame, from: window.screen.coordinateSpace) + } + + dockedSoftwareKeyboardOccupiesScreen = + FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: keyboardFrame, + screenBounds: window?.bounds, + isLocal: notification.userInfo?[UIResponder.keyboardIsLocalUserInfoKey] + as? Bool ?? true, + sceneIsActive: window?.windowScene?.activationState == .foregroundActive + ) + } + + private var usesAccessibilityKeyboardMetrics: Bool { + dynamicTypeSize.isAccessibilitySize + && focused.wrappedValue + && dockedSoftwareKeyboardOccupiesScreen + } +} + +private struct FeatureComposerWindowReader: UIViewRepresentable { + let onWindowChange: @MainActor (UIWindow?) -> Void + + func makeUIView(context: Context) -> WindowReportingView { + WindowReportingView(onWindowChange: onWindowChange) + } + + func updateUIView(_ view: WindowReportingView, context: Context) { + view.onWindowChange = onWindowChange + view.reportWindowIfNeeded() + } + + static func dismantleUIView(_ view: WindowReportingView, coordinator: Void) { + view.onWindowChange = nil + } + + @MainActor + final class WindowReportingView: UIView { + var onWindowChange: (@MainActor (UIWindow?) -> Void)? + private weak var reportedWindow: UIWindow? + + init(onWindowChange: @escaping @MainActor (UIWindow?) -> Void) { + self.onWindowChange = onWindowChange + super.init(frame: .zero) + isUserInteractionEnabled = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + reportWindowIfNeeded() + } + + func reportWindowIfNeeded() { + guard reportedWindow !== window else { return } + reportedWindow = window + let nextWindow = window + Task { @MainActor [weak self] in + self?.onWindowChange?(nextWindow) + } + } + } +} + +enum FeatureComposerKeyboardLayout { + private static let minimumSoftwareKeyboardHeight: CGFloat = 100 + private static let accessibilityKeyboardBottomClearance: CGFloat = 52 + + static func visibleLineRange( + dynamicTypeSize: DynamicTypeSize, + softwareKeyboardIsVisible: Bool + ) -> ClosedRange { + guard softwareKeyboardIsVisible else { return 1...7 } + return dynamicTypeSize.isAccessibilitySize ? (1...1) : (1...3) + } + + static func bottomClearance( + dynamicTypeSize: DynamicTypeSize, + softwareKeyboardIsVisible: Bool + ) -> CGFloat { + guard dynamicTypeSize.isAccessibilitySize, + softwareKeyboardIsVisible else { return 0 } + // Reserve one 44pt footer row plus 8pt breathing room above keyboard clipping. + return accessibilityKeyboardBottomClearance + } + + static func softwareKeyboardOccupiesScreen( + keyboardFrame: CGRect?, + screenBounds: CGRect?, + isLocal: Bool, + sceneIsActive: Bool = true + ) -> Bool { + guard sceneIsActive, + let keyboardFrame, + let screenBounds, + isLocal, + abs(keyboardFrame.maxY - screenBounds.maxY) < 1 else { return false } + return screenBounds.intersection(keyboardFrame).height + >= minimumSoftwareKeyboardHeight + } } enum FeatureComposerCollapsePolicy { diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift index a51db62c656..2af99d11621 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift @@ -1,3 +1,5 @@ +import CoreGraphics +import SwiftUI import Testing @testable import T3Code @@ -187,6 +189,121 @@ struct FeatureComposerPowerTests { #expect(reconciled == ["one": .text("keep")]) } + @Test + func dockedSoftwareKeyboardReducesTheComposerLineLimit() { + #expect( + FeatureComposerKeyboardLayout.visibleLineRange( + dynamicTypeSize: .large, + softwareKeyboardIsVisible: false + ) == (1...7) + ) + #expect( + FeatureComposerKeyboardLayout.visibleLineRange( + dynamicTypeSize: .large, + softwareKeyboardIsVisible: true + ) == (1...3) + ) + #expect( + FeatureComposerKeyboardLayout.visibleLineRange( + dynamicTypeSize: .accessibility5, + softwareKeyboardIsVisible: true + ) == (1...1) + ) + } + + @Test + func softwareKeyboardDetectionExcludesHiddenFloatingAndRemoteFrames() { + let screenBounds = CGRect(x: 0, y: 0, width: 368, height: 800) + let dockedFrame = CGRect(x: 0, y: 494, width: 368, height: 306) + + #expect( + FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: dockedFrame, + screenBounds: screenBounds, + isLocal: true + ) + ) + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: CGRect(x: 0, y: 800, width: 368, height: 306), + screenBounds: screenBounds, + isLocal: true + ) + ) + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: CGRect(x: 84, y: 400, width: 200, height: 200), + screenBounds: screenBounds, + isLocal: true + ) + ) + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: CGRect(x: 0, y: 745, width: 368, height: 55), + screenBounds: screenBounds, + isLocal: true + ) + ) + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: dockedFrame, + screenBounds: screenBounds, + isLocal: false + ) + ) + } + + @Test + func softwareKeyboardDetectionClearsWhenSceneContextIsUnavailableOrInactive() { + let screenBounds = CGRect(x: 0, y: 0, width: 368, height: 800) + let dockedFrame = CGRect(x: 0, y: 494, width: 368, height: 306) + + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: nil, + screenBounds: screenBounds, + isLocal: true + ) + ) + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: dockedFrame, + screenBounds: nil, + isLocal: true + ) + ) + #expect( + !FeatureComposerKeyboardLayout.softwareKeyboardOccupiesScreen( + keyboardFrame: dockedFrame, + screenBounds: screenBounds, + isLocal: true, + sceneIsActive: false + ) + ) + } + + @Test + func accessibilitySoftwareKeyboardReservesAComposerFooterRow() { + #expect( + FeatureComposerKeyboardLayout.bottomClearance( + dynamicTypeSize: .large, + softwareKeyboardIsVisible: true + ) == 0 + ) + #expect( + FeatureComposerKeyboardLayout.bottomClearance( + dynamicTypeSize: .accessibility5, + softwareKeyboardIsVisible: false + ) == 0 + ) + #expect( + FeatureComposerKeyboardLayout.bottomClearance( + dynamicTypeSize: .accessibility5, + softwareKeyboardIsVisible: true + ) == 52 + ) + } + @Test func onlyTheExplicitComposerButtonCanSend() { #expect( From 54cf050a63e789e0ff6633396ba93210aec16af7 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Mon, 10 Aug 2026 11:43:23 +1000 Subject: [PATCH 2/2] fix(swift-ios): keep new-thread composer above keyboard --- .../Features/Chat/FeatureComposerView.swift | 28 +++++++++++++++---- .../FeatureComposerPowerTests.swift | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 822b2b06058..8ca7cefeb13 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -98,8 +98,7 @@ struct FeatureComposerView: View { .bottom, FeatureComposerKeyboardLayout.bottomClearance( dynamicTypeSize: dynamicTypeSize, - softwareKeyboardIsVisible: focused.wrappedValue - && dockedSoftwareKeyboardOccupiesScreen + softwareKeyboardIsVisible: dockedSoftwareKeyboardOccupiesScreen ) ) .background { @@ -142,6 +141,22 @@ struct FeatureComposerView: View { ) { notification in updateSoftwareKeyboardState(from: notification, in: composerWindow) } + // New Thread autofocus can begin the keyboard transition before this + // sheet's composer has subscribed to the "will change" event. + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardDidShowNotification + ) + ) { notification in + updateSoftwareKeyboardState(from: notification, in: composerWindow) + } + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardDidHideNotification + ) + ) { _ in + dockedSoftwareKeyboardOccupiesScreen = false + } } private var composerSurface: some View { @@ -227,8 +242,7 @@ struct FeatureComposerView: View { .lineLimit( FeatureComposerKeyboardLayout.visibleLineRange( dynamicTypeSize: dynamicTypeSize, - softwareKeyboardIsVisible: focused.wrappedValue - && dockedSoftwareKeyboardOccupiesScreen + softwareKeyboardIsVisible: dockedSoftwareKeyboardOccupiesScreen ) ) .focused(focused) @@ -516,7 +530,6 @@ struct FeatureComposerView: View { private var usesAccessibilityKeyboardMetrics: Bool { dynamicTypeSize.isAccessibilitySize - && focused.wrappedValue && dockedSoftwareKeyboardOccupiesScreen } } @@ -578,7 +591,10 @@ enum FeatureComposerKeyboardLayout { softwareKeyboardIsVisible: Bool ) -> ClosedRange { guard softwareKeyboardIsVisible else { return 1...7 } - return dynamicTypeSize.isAccessibilitySize ? (1...1) : (1...3) + // New Thread's sheet can leave only enough vertical room for one input + // line plus the footer. A larger cap lets SwiftUI compress those views + // into each other even though the keyboard itself was detected. + return 1...1 } static func bottomClearance( diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift index 2af99d11621..4df2ee4105b 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift @@ -201,7 +201,7 @@ struct FeatureComposerPowerTests { FeatureComposerKeyboardLayout.visibleLineRange( dynamicTypeSize: .large, softwareKeyboardIsVisible: true - ) == (1...3) + ) == (1...1) ) #expect( FeatureComposerKeyboardLayout.visibleLineRange(