Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 180 additions & 5 deletions apps/swift-ios/Features/Chat/FeatureComposerView.swift
Original file line number Diff line number Diff line change
@@ -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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Chat/FeatureComposerView.swift:9

composerWindow is stored as a strong @State reference to UIWindow. The window retains its root view controller and view hierarchy, which includes this composer and its SwiftUI state, so dismissing the hierarchy can leave the entire window/composer graph retained — a memory leak. Consider wrapping the window in a weak reference holder rather than storing it directly in @State.

Suggested change
@State private var composerWindow: UIWindow?
+@State private var composerWindow: FeatureComposerWindowBox?
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Chat/FeatureComposerView.swift around line 9:

`composerWindow` is stored as a strong `@State` reference to `UIWindow`. The window retains its root view controller and view hierarchy, which includes this composer and its SwiftUI state, so dismissing the hierarchy can leave the entire window/composer graph retained — a memory leak. Consider wrapping the window in a weak reference holder rather than storing it directly in `@State`.

@State private var attachmentPreparation = FeatureAttachmentPreparationState()
@State private var pathEntries: [FeatureComposerPathEntry] = []
@State private var isPathSearchLoading = false
Expand Down Expand Up @@ -90,6 +94,13 @@ struct FeatureComposerView: View {
.padding(.horizontal, 12)
.padding(.top, 12)
.padding(.bottom, 10)
.padding(
.bottom,
FeatureComposerKeyboardLayout.bottomClearance(
dynamicTypeSize: dynamicTypeSize,
softwareKeyboardIsVisible: dockedSoftwareKeyboardOccupiesScreen
)
)
.background {
LinearGradient(
colors: [
Expand All @@ -102,6 +113,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,
Expand All @@ -116,6 +134,29 @@ struct FeatureComposerView: View {
.task(id: pathSearchRequest) {
await updatePathSearch()
}
.onReceive(
NotificationCenter.default.publisher(
for: UIResponder.keyboardWillChangeFrameNotification
)
) { 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 {
Expand Down Expand Up @@ -198,14 +239,22 @@ struct FeatureComposerView: View {
axis: .vertical
)
.font(T3Typography.composer)
.lineLimit(1...7)
.lineLimit(
FeatureComposerKeyboardLayout.visibleLineRange(
dynamicTypeSize: dynamicTypeSize,
softwareKeyboardIsVisible: 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")
Expand All @@ -227,6 +276,8 @@ struct FeatureComposerView: View {
}

composerFooter
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
}
}

Expand Down Expand Up @@ -260,7 +311,7 @@ struct FeatureComposerView: View {
}
.padding(.horizontal, 7)
.padding(.top, 2)
.padding(.bottom, 8)
.padding(.bottom, usesAccessibilityKeyboardMetrics ? 2 : 8)
}

private var submitButton: some View {
Expand Down Expand Up @@ -446,6 +497,130 @@ 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
&& 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<Int> {
guard softwareKeyboardIsVisible else { return 1...7 }
// 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(
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 {
Expand Down
117 changes: 117 additions & 0 deletions apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import CoreGraphics
import SwiftUI
import Testing
@testable import T3Code

Expand Down Expand Up @@ -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...1)
)
#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(
Expand Down
Loading